-----------------------------------

Acquista i software ArcGIS tramite Studio A&T srl, rivenditore autorizzato dei prodotti Esri.

I migliori software GIS, il miglior supporto tecnico!

I migliori software GIS, il miglior supporto tecnico!
Azienda operante nel settore GIS dal 2001, specializzata nell’utilizzo della tecnologia ArcGIS e aderente ai programmi Esri Italia Business Network ed Esri Partner Network

-----------------------------------



Visualizzazione post con etichetta c#. Mostra tutti i post
Visualizzazione post con etichetta c#. Mostra tutti i post

venerdì 9 ottobre 2026

Agentic Planning vs Deterministic Execution: architetture AI affidabili con Azure Durable Functions, DAG e GeoAgent

Quando l'intelligenza pianifica, ma non controlla l'esecuzione

Per molto tempo abbiamo progettato gli agenti AI secondo un modello relativamente semplice: un Large Language Model interpreta una richiesta, seleziona uno strumento, ne osserva il risultato e decide cosa fare successivamente.

Il modello funziona bene per task brevi. Diventa però progressivamente meno efficiente quando deve coordinare decine di strumenti, elaborare grandi volumi di dati, gestire retry, attendere eventi esterni e garantire che operazioni già completate non vengano ripetute accidentalmente.

Con Dynamic Workflows for Azure Functions Hosted Skills, annunciato da Microsoft l'8 ottobre 2026, emerge un'architettura differente: l'LLM genera un piano strutturato, mentre un runtime durevole e deterministico si occupa della sua esecuzione.

La distinzione è fondamentale. Il modello non diventa improvvisamente affidabile perché produce JSON anziché testo. È il sistema che interpreta quel JSON a dover imporre vincoli, autorizzazioni, dipendenze e garanzie operative.

Principio architetturale: un LLM può proporre una sequenza di azioni, ma non dovrebbe essere l'autorità finale che decide se quelle azioni sono consentite, sicure o già state eseguite.

1. Il problema del reasoning loop tradizionale

Consideriamo un agente che deve analizzare un evento meteorologico, interrogare servizi GIS, produrre una mappa di rischio e inviare una notifica.

User / Event
    |
    v
   LLM
    |
    +-- Tool: read rainfall
    |       |
    |       v
    |      LLM
    |
    +-- Tool: read terrain
    |       |
    |       v
    |      LLM
    |
    +-- Tool: run spatial analysis
    |       |
    |       v
    |      LLM
    |
    +-- Tool: generate map
    |       |
    |       v
    |      LLM
    |
    +-- Tool: publish result
            |
            v
           LLM

Ogni operazione comporta un nuovo passaggio attraverso il modello. Se gli strumenti restituiscono dati voluminosi, il contesto si riempie di risultati intermedi che il modello potrebbe non aver bisogno di interpretare direttamente.

Il problema non è soltanto economico. È anche architetturale:

  • Latenza: le chiamate al modello possono serializzare operazioni indipendenti.
  • Context pollution: risultati intermedi e log possono occupare una parte significativa della context window.
  • Recovery: un crash durante un'operazione lunga richiede meccanismi di ripresa espliciti.
  • Retry: ripetere un tool può duplicare effetti esterni.
  • Audit: ricostruire perché un'azione sia stata eseguita può diventare difficile.
  • Security: il contenuto restituito da un tool può influenzare impropriamente le decisioni successive dell'agente.

Un reasoning loop è appropriato quando ogni risultato richiede una nuova decisione semantica. Non è necessariamente la scelta migliore quando le operazioni sono note, verificabili e largamente indipendenti.

2. L'architettura emergente: Plan, Validate, Execute, Verify

La nuova separazione delle responsabilità può essere rappresentata così:

                 USER / EVENT
                      |
                      v
              +---------------+
              |  LLM PLANNER  |
              +---------------+
                      |
                 Proposed DAG
                      |
                      v
              +---------------+
              | DAG VALIDATOR |
              | Policy Engine |
              +---------------+
                      |
                Approved Plan
                      |
                      v
              +---------------+
              | DURABLE ENGINE|
              +---------------+
                /     |     \
               v      v      v
           Tool A   Tool B   Tool C
               \      |      /
                \     |     /
                 v    v    v
                AGGREGATION
                      |
                      v
                VERIFICATION
                      |
                      v
                FINAL RESULT

Il passaggio più importante è quello fra planner e runtime.

Il piano generato dal modello deve essere considerato un input non attendibile, anche quando il modello appartiene a un provider affidabile. La validazione non può essere una semplice verifica della sintassi JSON: deve includere controlli strutturali, semantici, autorizzativi e di consumo delle risorse.

Microsoft applica questo paradigma nella preview Dynamic Workflows di Azure Functions Hosted Skills. Il runtime utilizza Durable Functions per eseguire il piano senza riportare continuamente i risultati intermedi nel reasoning loop dell'LLM.

È importante distinguere la funzionalità specifica Microsoft, attualmente in preview e basata su tool Python, da un'implementazione equivalente realizzata autonomamente in C# e .NET con Durable Functions.

3. Il DAG come contratto eseguibile

Un Directed Acyclic Graph è un grafo diretto senza cicli. Ogni nodo rappresenta un'operazione; ogni arco rappresenta una dipendenza.

Consideriamo un piano per valutare il rischio associato a precipitazioni intense:

         ReadRainfall
              |
              +----------------+
              |                |
              v                v
        LoadTerrain      LoadAssets
              |                |
              v                |
        ComputeRunoff          |
              |                |
              +--------+-------+
                       |
                       v
                IntersectRisk
                       |
                       v
                 GenerateMap
                       |
                       v
                PublishResult

Il DAG esplicita immediatamente il parallelismo possibile: LoadTerrain e LoadAssets possono partire quando ReadRainfall è completato, se le rispettive operazioni non dipendono l'una dall'altra.

In un sistema reale conviene spingersi oltre, rendendo indipendenti tutte le acquisizioni che non richiedono il risultato delle precipitazioni.

Un contratto JSON minimale

{
  "schemaVersion": "1.0",
  "workflowId": "rainfall-risk-20261009",
  "nodes": [
    {
      "id": "rainfall",
      "tool": "gis.readRainfall",
      "dependsOn": []
    },
    {
      "id": "terrain",
      "tool": "gis.loadTerrain",
      "dependsOn": []
    },
    {
      "id": "assets",
      "tool": "gis.loadAssets",
      "dependsOn": []
    },
    {
      "id": "risk",
      "tool": "gis.computeRisk",
      "dependsOn": [
        "rainfall",
        "terrain",
        "assets"
      ]
    },
    {
      "id": "map",
      "tool": "gis.generateMap",
      "dependsOn": ["risk"]
    }
  ]
}

Questo JSON è una rappresentazione concettuale del nostro contratto applicativo, non lo schema ufficiale della preview Microsoft.

Un contratto production-grade dovrebbe inoltre includere versione del piano, input tipizzati, riferimenti agli output, classificazione dei tool, limiti di esecuzione e policy di autorizzazione. Gli argomenti non devono poter contenere endpoint arbitrari o istruzioni eseguibili non previste.

4. DAG validation: il vero confine di sicurezza

La validazione di un piano prodotto da un LLM deve essere eseguita prima che il workflow venga avviato.

I controlli minimi comprendono:

  • Identificatori univoci dei nodi.
  • Assenza di dipendenze inesistenti.
  • Assenza di cicli.
  • Limiti su numero di nodi, profondità e fan-out.
  • Tool presenti in una allowlist server-side.
  • Compatibilità tra input e output dei tool.
  • Autorizzazioni coerenti con utente, tenant e risorsa.
  • Limiti su costo, durata e concorrenza.
  • Approvazioni esplicite per operazioni con effetti esterni sensibili.

Validazione strutturale in C#

public sealed record WorkflowNode(
    string Id,
    string Tool,
    string[] DependsOn);

public sealed record WorkflowPlan(
    string SchemaVersion,
    WorkflowNode[] Nodes);

public static class DagValidator
{
    private static readonly HashSet<string> AllowedTools =
        new(StringComparer.Ordinal)
        {
            "gis.readRainfall",
            "gis.loadTerrain",
            "gis.loadAssets",
            "gis.computeRisk",
            "gis.generateMap"
        };

    public static void Validate(WorkflowPlan plan)
    {
        ArgumentNullException.ThrowIfNull(plan);

        if (plan.SchemaVersion != "1.0")
            throw new InvalidOperationException(
                "Unsupported schema version.");

        if (plan.Nodes is null ||
            plan.Nodes.Length is < 1 or > 50)
            throw new InvalidOperationException(
                "Invalid workflow size.");

        var nodes = new Dictionary<string, WorkflowNode>(
            StringComparer.Ordinal);

        foreach (var node in plan.Nodes)
        {
            if (string.IsNullOrWhiteSpace(node.Id) ||
                !nodes.TryAdd(node.Id, node))
                throw new InvalidOperationException(
                    "Invalid or duplicate node ID.");

            if (!AllowedTools.Contains(node.Tool))
                throw new InvalidOperationException(
                    $"Tool not allowed: {node.Tool}");
        }

        var indegree = nodes.Keys.ToDictionary(
            id => id,
            _ => 0,
            StringComparer.Ordinal);

        var children = nodes.Keys.ToDictionary(
            id => id,
            _ => new List<string>(),
            StringComparer.Ordinal);

        foreach (var node in nodes.Values)
        {
            var dependencies = node.DependsOn ??
                throw new InvalidOperationException(
                    "Dependencies cannot be null.");

            foreach (var dependency in dependencies.Distinct())
            {
                if (!nodes.ContainsKey(dependency))
                    throw new InvalidOperationException(
                        $"Unknown dependency: {dependency}");

                indegree[node.Id]++;
                children[dependency].Add(node.Id);
            }
        }

        var ready = new Queue<string>(
            indegree
                .Where(x => x.Value == 0)
                .Select(x => x.Key));

        var visited = 0;

        while (ready.Count > 0)
        {
            var id = ready.Dequeue();
            visited++;

            foreach (var child in children[id])
            {
                if (--indegree[child] == 0)
                    ready.Enqueue(child);
            }
        }

        if (visited != nodes.Count)
            throw new InvalidOperationException(
                "Workflow contains a cycle.");
    }
}

L'algoritmo utilizza una variante del topological sorting di Kahn. La complessità è O(V + E), dove V rappresenta i nodi ed E le dipendenze.

Questo codice valida soltanto la struttura e una allowlist di base. Non rappresenta un security validator completo. In produzione servono anche controllo degli argomenti, autorizzazione contestuale, compatibilità dei tipi, quote e validazione della provenienza del piano.

Un ulteriore accorgimento consiste nel calcolare un hash canonico del piano validato, registrando insieme ad esso identità del richiedente, versione delle policy e identificativo dell'esecuzione. Il runtime dovrebbe eseguire esattamente quel piano immutabile, evitando modifiche successive alla validazione.

5. Durable Functions: perché l'orchestrazione deve essere deterministica

Azure Durable Functions utilizza una forma di event sourcing per ricostruire lo stato delle orchestrazioni.

Quando un'orchestrazione riprende dopo un evento, il runtime può rieseguire il codice dell'orchestratore dall'inizio, recuperando dalla history i risultati delle attività già completate.

Questo meccanismo prende il nome di replay.

Execution history
       |
       +-- ActivityScheduled(A)
       +-- ActivityCompleted(A)
       +-- ActivityScheduled(B)
       +-- ActivityCompleted(B)
       |
       v
  Orchestrator Replay
       |
       +-- A: result from history
       +-- B: result from history
       +-- C: schedule new activity

Il codice dell'orchestratore deve quindi essere deterministico. Non dovrebbe effettuare direttamente chiamate HTTP, interrogazioni SQL, operazioni GIS o letture dell'orologio di sistema.

Le operazioni con effetti esterni appartengono alle Activity Functions.

Esempio di orchestrazione C# con .NET isolated worker

using Microsoft.Azure.Functions.Worker;
using Microsoft.DurableTask;

public static class GeoWorkflow
{
    [Function(nameof(RunGeoWorkflow))]
    public static async Task<string> RunGeoWorkflow(
        [OrchestrationTrigger]
        TaskOrchestrationContext context)
    {
        var rainfallTask =
            context.CallActivityAsync<string>(
                "ReadRainfall");

        var terrainTask =
            context.CallActivityAsync<string>(
                "LoadTerrain");

        var assetsTask =
            context.CallActivityAsync<string>(
                "LoadAssets");

        await Task.WhenAll(
            rainfallTask,
            terrainTask,
            assetsTask);

        var input = new RiskInput(
            rainfallTask.Result,
            terrainTask.Result,
            assetsTask.Result);

        var risk =
            await context.CallActivityAsync<string>(
                "ComputeRisk",
                input);

        return await context.CallActivityAsync<string>(
            "GenerateMap",
            risk);
    }
}

public sealed record RiskInput(
    string RainfallUri,
    string TerrainUri,
    string AssetsUri);

Questo è un esempio di orchestrazione statica. Non è ancora un interprete dinamico di DAG: serve a mostrare il modello Durable Functions e il parallelismo fan-out/fan-in.

Le tre Activity iniziali restituiscono riferimenti a dati persistenti, non interi raster o dataset serializzati nella history dell'orchestrazione.

Per interpretare DAG generati dinamicamente è preferibile utilizzare un executor generico che riceva un piano validato, identifichi i nodi pronti e li programmi attraverso un catalogo di Activity consentite.

6. Un DAG executor dinamico: la differenza fra scheduling e reasoning

Il runtime non deve chiedere all'LLM quale nodo eseguire dopo ogni completamento. Deve applicare una regola deterministica:

Ready(node) =
    AllDependenciesCompleted(node)
    AND PolicyAllows(node)
    AND ResourcesAvailable(node)

Un possibile algoritmo è:

completed = {}
pending = allNodes

while pending is not empty:

    ready = nodes where:
        every dependency is completed

    if ready is empty:
        fail invalid workflow

    execute ready nodes concurrently
    await completion

    persist outputs
    mark nodes completed
    remove nodes from pending

Questo pseudocodice illustra l'idea, ma in produzione richiede alcuni raffinamenti importanti.

Prima di tutto, i nodi devono essere ordinati stabilmente per evitare divergenze durante il replay. Inoltre la concorrenza deve essere limitata, gli errori devono essere gestiti per nodo e il risultato di ogni Activity deve essere registrato con un identificatore stabile.

Un semplice Task.WhenAll() su tutti i nodi pronti può produrre fan-out eccessivo. In un workflow geografico, cinquanta richieste simultanee a un servizio ArcGIS possono saturare il servizio, esaurire risorse o produrre throttling.

È quindi utile distinguere tre livelli di concorrenza:

  • Workflow concurrency: quante orchestrazioni possono essere attive.
  • Node concurrency: quanti nodi dello stesso DAG possono essere eseguiti contemporaneamente.
  • Resource concurrency: quante operazioni possono utilizzare uno specifico servizio, database o pool di worker.

Il terzo livello è particolarmente importante: limitare il fan-out di una singola orchestrazione non impedisce a cento orchestrazioni concorrenti di sovraccaricare lo stesso servizio.

7. Idempotenza: at-least-once non significa exactly-once

Uno dei problemi più pericolosi dei workflow distribuiti è la ripetizione delle operazioni.

Durable Functions può rieseguire un'Activity quando si verifica un errore o quando il completamento dell'attività non è stato registrato correttamente.

Supponiamo che un'Activity esegua:

POST /arcgis/rest/services/Analysis/GPServer/SubmitJob

Il servizio remoto riceve la richiesta e avvia il job. Subito dopo, il worker Azure Functions si interrompe prima di registrare il risultato.

Al riavvio, il runtime può ripetere l'Activity.

Il risultato potrebbe essere:

First attempt  --> ArcGIS Job 1001
Worker crash
Second attempt --> ArcGIS Job 1002

Il sistema ha prodotto due elaborazioni per una sola richiesta logica.

Il retry è quindi corretto dal punto di vista dell'infrastruttura, ma non necessariamente dal punto di vista del business.

La soluzione: Operation Ledger e Idempotency Key

Ogni operazione con effetti esterni dovrebbe avere una chiave stabile:

IdempotencyKey =
    WorkflowId + ":" + NodeId

Una tabella SQL può mantenere lo stato dell'operazione:

CREATE TABLE WorkflowOperations
(
    OperationKey NVARCHAR(200) NOT NULL
        PRIMARY KEY,

    State NVARCHAR(30) NOT NULL,

    ExternalJobId NVARCHAR(200) NULL,

    ResultUri NVARCHAR(2000) NULL,

    UpdatedAt DATETIMEOFFSET NOT NULL
);

Il pattern operativo diventa:

1. Reserve OperationKey atomically
2. Check previous execution state
3. Submit or reconcile external job
4. Persist ExternalJobId
5. Poll external job
6. Persist ResultUri
7. Mark operation Completed

È fondamentale riconoscere che il ledger, da solo, non garantisce exactly-once.

Esiste infatti una finestra critica fra l'invio della richiesta al sistema esterno e il salvataggio dell'ExternalJobId. Se il processo si interrompe in quel punto, il database locale non sa se l'operazione remota sia stata accettata.

Per eliminare questa ambiguità serve un meccanismo di riconciliazione: una chiave di idempotenza supportata dal servizio remoto, un identificatore applicativo ricercabile oppure un adapter che possa individuare un job già creato.

Se l'API esterna non supporta alcuna forma di deduplicazione o riconciliazione, la garanzia di exactly-once non può essere ottenuta semplicemente aggiungendo retry e una tabella SQL.

8. Retry, timeout, circuit breaker e compensazione

Un workflow distribuito affidabile deve distinguere gli errori transitori da quelli permanenti.

Un HTTP 429 o 503 può giustificare un retry con backoff. Un errore di autorizzazione, un input GIS non valido o un piano che viola una policy non dovrebbe invece essere ripetuto automaticamente.

Per gli errori transitori è possibile utilizzare le retry policy delle Activity Durable Functions.

Per operazioni che modificano sistemi esterni è inoltre utile introdurre una logica di compensazione.

Consideriamo:

CreateAnalysisJob
        |
        v
PublishFeatureLayer
        |
        v
UpdateWebMap
        |
        v
NotifyUsers

Se UpdateWebMap fallisce dopo la pubblicazione del layer, il sistema potrebbe dover eliminare il layer temporaneo o ripristinare la configurazione precedente.

Questo modello è vicino al pattern Saga: ogni azione può avere un'azione compensativa, ma la compensazione non equivale necessariamente a un rollback transazionale perfetto.

Per un workflow geografico occorre inoltre distinguere:

  • Risultati temporanei, eliminabili automaticamente.
  • Risultati pubblicati, che richiedono autorizzazioni specifiche per la rimozione.
  • Modifiche a dati autorevoli, che possono richiedere approvazione umana.
  • Notifiche già inviate, che non possono essere semplicemente annullate.

La progettazione delle compensazioni deve essere parte del catalogo dei tool, non una decisione improvvisata dall'LLM dopo un errore.

9. Isolamento dei sub-agent: un agente specializzato non è un processo fidato

Un workflow può delegare attività a sub-agent specializzati.

Per esempio:

GeoCoordinator
      |
      +-- RainfallAgent
      |
      +-- TerrainAgent
      |
      +-- InfrastructureAgent
      |
      +-- RiskVerificationAgent

Il vantaggio è la specializzazione. Il rischio è l'ampliamento della superficie di attacco.

Un sub-agent non dovrebbe ereditare automaticamente l'intera cronologia del coordinatore, tutte le sue credenziali o la capacità di invocare qualsiasi tool.

Nella preview Microsoft Dynamic Workflows, le invocazioni dei sub-agent utilizzano sessioni isolate e stateless, con istruzioni, modelli, strumenti e timeout propri. Non ricevono automaticamente la conversation history o la sandbox del coordinatore.

Questo è un buon punto di partenza, ma non sostituisce l'isolamento infrastrutturale.

Capability-based execution

Ogni sub-agent dovrebbe ricevere soltanto le capability necessarie.

RainfallAgent
    READ rainfall observations
    READ station metadata
    NO write access
    NO publishing permissions

TerrainAgent
    READ elevation datasets
    EXECUTE terrain analysis
    NO access to notifications

PublisherAgent
    READ approved outputs
    PUBLISH to approved folders
    NO arbitrary service access

Il controllo deve essere imposto dal gateway dei tool e dalle identità utilizzate verso i servizi, non soltanto da istruzioni testuali come "non pubblicare dati".

In Azure, Managed Identity, Azure RBAC, Key Vault e isolamento di rete possono contribuire a questo modello, purché le identità siano effettivamente separate secondo i confini di autorizzazione richiesti.

10. Context engineering: la memoria dell'agente non è lo stato del workflow

Un altro errore frequente consiste nell'utilizzare la conversation history come archivio dello stato operativo.

La memoria del modello è utile per mantenere obiettivi, vincoli e informazioni semantiche. Non dovrebbe essere la fonte autorevole per determinare se un job è completato, quale output è stato pubblicato o quali autorizzazioni sono state concesse.

Propongo di separare quattro livelli:

+----------------------------------+
| Semantic Context                 |
| Goal, assumptions, constraints   |
+----------------------------------+
| Execution State                  |
| Node status, retries, timestamps |
+----------------------------------+
| Artifact Store                   |
| Raster, JSON, logs, results      |
+----------------------------------+
| Audit & Authorization            |
| Identity, approvals, provenance  |
+----------------------------------+

Il modello dovrebbe ricevere soltanto il contesto necessario per decidere il piano e, alla fine, interpretare i risultati.

Gli output voluminosi dovrebbero essere conservati in storage e rappresentati attraverso riferimenti immutabili o versionati, accompagnati da metadata come checksum, schema, sistema di riferimento e provenienza.

Questo evita che raster, grandi FeatureSet, log o migliaia di record GIS vengano inseriti nella context window senza alcun beneficio.

11. GeoAgent: un laboratorio architetturale con ArcGIS e Azure

Immaginiamo di voler realizzare un agente che risponda alla richiesta:

Analizza le precipitazioni delle ultime 24 ore, individua le aree potenzialmente critiche e produci una mappa delle infrastrutture esposte.

Il modello non dovrebbe generare direttamente una mappa di rischio basandosi soltanto sul testo. Deve selezionare strumenti GIS deterministici, ciascuno con input, output e limiti ben definiti.

Pipeline proposta

Weather / Rainfall API
          |
          v
   Observation Validation
          |
          v
   Spatial Interpolation
      IDW / Spline
          |
          v
   Rainfall Raster
          |
          +------------------+
          |                  |
          v                  v
    Terrain Data       Asset Inventory
          |                  |
          +---------+--------+
                    |
                    v
             Spatial Analysis
                    |
                    v
             Exposure Overlay
                    |
                    v
             Result Validation
                    |
                    v
             ArcGIS Publishing
                    |
                    v
               WebMap / App

Una possibile implementazione utilizza:

  • C#/.NET: API di ingresso, validazione, orchestrazione e integrazione enterprise.
  • Azure Durable Functions: scheduling durevole, retry e recovery.
  • Azure SQL: operation ledger, metadata, policy e audit applicativo.
  • Azure Blob Storage: artifact store per input e output voluminosi.
  • ArcGIS Enterprise: geoprocessing, analisi raster, feature service e pubblicazione.
  • LLM: interpretazione della richiesta, costruzione del piano e sintesi finale.

È importante non confondere l'interpolazione delle precipitazioni con una simulazione idrologica o idraulica completa.

IDW e Spline producono superfici interpolate a partire dalle osservazioni. Per modellare runoff, propagazione delle piene o dinamiche fisiche occorrono modelli e dati aggiuntivi, opportunamente calibrati e validati.

Inoltre l'eventuale esecuzione di Spatial Analyst richiede di verificare disponibilità delle capability e licenze nella versione e nell'ambiente ArcGIS effettivamente distribuiti.

12. Un adapter ArcGIS robusto deve conoscere il ciclo di vita dei job

Un adapter per un servizio ArcGIS Geoprocessing asincrono non dovrebbe limitarsi a invocare SubmitJob e attendere indefinitamente.

Deve modellare esplicitamente gli stati del job remoto:

NotSubmitted
      |
      v
  Submitted
      |
      v
   Executing
      |
      +----> Succeeded
      |
      +----> Failed
      |
      +----> Cancelled
      |
      +----> TimedOut

La gestione deve distinguere lo stato del job ArcGIS dallo stato dell'Activity Azure Functions.

Un job ArcGIS può continuare a essere eseguito anche quando il worker che lo ha avviato non è più disponibile. Per questo è preferibile salvare l'identificatore del job e utilizzare un meccanismo di polling o riconciliazione durevole.

Per processi molto lunghi, il pattern può diventare:

Submit GP Job
     |
     v
Persist Job ID
     |
     v
Durable Timer
     |
     v
Check GP Status
     |
     +-- Running --> Durable Timer
     |
     +-- Success --> Fetch Results
     |
     +-- Failure --> Error Policy

Il polling non dovrebbe occupare un thread o una Function instance per tutta la durata del job.

Per i servizi GIS occorre inoltre definire timeout, frequenza di polling, politiche di cancellazione e comportamento in caso di perdita temporanea della connettività.

13. Observability: misurare il workflow, non soltanto i token

Un sistema agentico enterprise dovrebbe essere osservabile su più livelli.

Il livello LLM deve registrare modello, versione, token, latenza, versione del prompt e identificativo del piano generato.

Il livello workflow deve registrare durata dei nodi, retry, timeout, dipendenze, errori e stato complessivo.

Il livello GIS deve registrare servizio utilizzato, identificativo del job, tempi di elaborazione, parametri analitici, dataset e riferimenti agli output.

Una struttura di correlazione può essere:

TraceId
   |
   +-- PlanningSpan
   |
   +-- ValidationSpan
   |
   +-- WorkflowInstanceId
          |
          +-- Node: rainfall
          +-- Node: terrain
          +-- Node: risk
          +-- Node: publishing
                    |
                    +-- ArcGIS JobId

OpenTelemetry e Application Insights possono contribuire a questa osservabilità, ma occorre propagare esplicitamente gli identificatori di correlazione fra sistemi.

Il replay delle orchestrazioni richiede inoltre attenzione: log prodotti ingenuamente durante il replay possono essere duplicati. Dove disponibile, conviene utilizzare il replay-safe logger fornito dal runtime Durable Functions.

Metriche che misurerei

  • Planning latency e planning token cost.
  • Percentuale di piani rifiutati dal validator.
  • Numero di nodi e profondità media del DAG.
  • Parallelismo effettivo rispetto a quello teorico.
  • Retry per tool e per servizio esterno.
  • Numero di job remoti riconciliati dopo failure.
  • Workflow completion rate.
  • Tempo medio e percentile P95/P99 per nodo.
  • Costo per workflow completato.
  • Accuratezza e validità dei risultati GIS.

Una riduzione dei token non rappresenta automaticamente un miglioramento del sistema se aumentano i workflow falliti o diminuisce la qualità del risultato.

14. Sicurezza: il piano generato dall'LLM è una superficie di attacco

Un piano apparentemente valido può essere pericoloso anche senza contenere codice eseguibile.

Consideriamo alcuni scenari:

  • Tool escalation: il modello seleziona un tool di pubblicazione quando l'utente ha richiesto soltanto un'analisi.
  • Data exfiltration: un parametro contiene un endpoint esterno non autorizzato.
  • Resource exhaustion: il piano richiede centinaia di analisi raster molto costose.
  • Cross-tenant access: un nodo tenta di utilizzare dati appartenenti a un altro tenant.
  • Prompt injection: un dataset o documento esterno influenza il planner inducendolo a inserire operazioni non richieste.
  • Approval spoofing: un output intermedio dichiara falsamente che l'utente ha autorizzato un'azione.

Le contromisure devono essere applicate fuori dal modello:

Untrusted User / Tool Data
             |
             v
        LLM Planner
             |
             v
        Schema Validator
             |
             v
        Policy Decision
             |
             v
       Approval Gateway
             |
             v
        Tool Dispatcher
             |
             v
     Authorized Execution

Un'approvazione dovrebbe essere un evento autenticato e correlato a una specifica operazione, non una proprietà booleana modificabile dal modello.

Il tool dispatcher dovrebbe risolvere le credenziali dal contesto server-side, evitando che il piano possa scegliere liberamente identità, segreti o endpoint.

15. Benchmark Microsoft: cosa dimostrano realmente i numeri

Nel proprio annuncio, Microsoft ha confrontato un normale reasoning loop con Dynamic Workflows su un benchmark sintetico di analisi di dieci servizi.

I risultati riportati sono:

MetricaReasoning loopDynamic Workflow
Token utilizzati99.0976.781
Latenza247,1 s12,7 s
Riduzione token—93,1%
Riduzione latenza—95,1%

I numeri sono notevoli, ma vanno interpretati correttamente. Il benchmark riguarda un task specifico, strutturato e adatto alla parallelizzazione. Non significa che qualsiasi workflow agentico possa ottenere automaticamente riduzioni equivalenti.

Il vantaggio dipende soprattutto da tre fattori:

  • Le attività indipendenti possono essere eseguite in parallelo.
  • Gli output intermedi non devono attraversare continuamente il modello.
  • Il numero di round-trip LLM viene drasticamente ridotto.

Un workflow che richiede decisioni semantiche dopo ogni passaggio potrebbe invece beneficiare meno di questo modello.

16. Il trade-off: meno autonomia durante l'esecuzione, più affidabilità

Separare pianificazione ed esecuzione introduce anche un limite.

Un DAG statico non può adattarsi arbitrariamente a qualsiasi informazione inattesa. Se un risultato intermedio richiede una nuova strategia, il workflow deve prevedere una decisione condizionale oppure una fase esplicita di replanning.

Per questo considero particolarmente interessante un modello ibrido:

LLM Planning
      |
      v
Validated DAG
      |
      v
Deterministic Execution
      |
      +-- Expected Result
      |       |
      |       v
      |   Continue DAG
      |
      +-- Unexpected Result
              |
              v
        Controlled Replan
              |
              v
        New Validation
              |
              v
        Resume Execution

Il replanning deve essere limitato e tracciato. Ogni nuovo piano deve essere nuovamente validato, e il sistema deve conoscere quali operazioni precedenti siano già state completate.

Non dovrebbe essere possibile aggirare un rifiuto del validator semplicemente generando ripetutamente nuovi piani.

17. Verso una nuova architettura: il GeoAgent come compilatore di workflow geografici

La prospettiva più interessante è considerare l'LLM non come il motore di esecuzione, ma come un compilatore semantico.

L'utente esprime un obiettivo in linguaggio naturale. Il modello lo traduce in una rappresentazione intermedia tipizzata. Il sistema valida questa rappresentazione, la ottimizza e la esegue utilizzando strumenti GIS affidabili.

Natural Language Goal
          |
          v
      LLM Planner
          |
          v
    GeoWorkflow IR
          |
          v
   Semantic Validation
          |
          v
   Optimization Passes
          |
          v
    Execution Plan
          |
          v
 Durable GIS Runtime
          |
          v
 Verified GeoArtifacts

Questa analogia con un compilatore è particolarmente potente.

La rappresentazione intermedia potrebbe contenere informazioni tipizzate come:

  • Sistema di riferimento spaziale.
  • Tipo geometrico.
  • Risoluzione raster.
  • Unità di misura.
  • Estensione geografica.
  • Dipendenze tra dataset.
  • Requisiti di licenza e capability GIS.
  • Classificazione dei dati.
  • Stima del costo computazionale.

Il validator potrebbe rifiutare un'operazione che combina raster con sistemi di riferimento incompatibili, utilizza unità di misura non coerenti oppure richiede capability non disponibili.

Un optimizer potrebbe invece individuare acquisizioni parallelizzabili, eliminare elaborazioni duplicate, riutilizzare risultati già presenti nello storage o selezionare differenti backend di elaborazione.

È importante distinguere ciò che è già disponibile da ciò che rappresenta una possibile evoluzione progettuale: questa GeoWorkflow IR è una proposta architetturale, non un prodotto Esri o Microsoft attualmente disponibile con tale nome.

18. Oltre il DAG: workflow gerarchici, feedback loop e sistemi autonomi

Il DAG è ideale per rappresentare dipendenze acicliche. Non descrive però naturalmente sistemi che devono reagire indefinitamente a nuovi eventi.

Un sistema di monitoraggio territoriale continuo può richiedere cicli di osservazione, analisi, verifica e aggiornamento.

La soluzione non è introdurre cicli arbitrari dentro un DAG non controllato, ma comporre workflow durevoli di durata limitata attraverso eventi, sub-orchestrazioni e nuove istanze.

Observation Event
       |
       v
Analysis Workflow
       |
       v
Verification
       |
       +-- No Change --> Archive
       |
       +-- Relevant Change
                  |
                  v
             New Event
                  |
                  v
          Follow-up Workflow

In questo modo ogni esecuzione rimane identificabile, verificabile e soggetta a quote, mentre il sistema complessivo può funzionare continuamente.

È una distinzione importante per il futuro degli agenti persistenti: persistenza della responsabilità non significa esecuzione illimitata di un singolo processo o di una singola conversazione LLM.

19. Checklist di produzione

Prima di portare in produzione un'architettura agentic workflow, verificherei almeno questi aspetti:

  • Il piano viene validato prima dell'esecuzione.
  • Ogni tool è registrato in un catalogo server-side.
  • Gli input sono tipizzati e soggetti a limiti.
  • Le autorizzazioni vengono verificate sul backend.
  • Le operazioni con effetti esterni hanno idempotency key e riconciliazione.
  • Gli orchestratori rispettano i vincoli di determinismo del replay.
  • I job remoti possono essere recuperati dopo un crash.
  • Gli output voluminosi sono esternalizzati dalla history.
  • I sub-agent hanno capability limitate.
  • Le approvazioni sono eventi autenticati.
  • Il parallelismo è limitato anche a livello di risorsa condivisa.
  • Le modifiche ai workflow già attivi sono gestite con versioning.
  • Gli audit log sono protetti da modifiche non autorizzate.
  • Le metriche misurano correttezza, costo e affidabilità, non soltanto latenza.
  • Esistono test di fault injection per crash, timeout, duplicazioni e retry.

20. Conclusione: l'LLM non deve diventare il transaction manager

Dynamic Workflows rappresenta un passaggio significativo nell'evoluzione delle architetture agentiche.

Non perché renda i modelli più intelligenti, ma perché riconosce esplicitamente che intelligenza, autorizzazione ed esecuzione affidabile sono problemi differenti.

Il modello interpreta obiettivi ambigui e propone piani. Il validator controlla che quei piani siano ammissibili. Il workflow engine esegue le attività rispettando dipendenze, retry e checkpoint. I tool applicano le operazioni reali attraverso API e identità autorizzate.

Nel mondo GIS questa separazione è ancora più importante: un LLM può scegliere quali analisi geospaziali effettuare, ma la correttezza geometrica, statistica e fisica deve rimanere responsabilità di algoritmi, dati e procedure verificabili.

La prossima generazione di GeoAgent potrebbe quindi non essere un chatbot con accesso a qualche geoprocessing tool, ma un vero sistema capace di compilare richieste territoriali in workflow geografici tipizzati, verificabili, riproducibili e osservabili.

La direzione è chiara: l'LLM definisce l'intenzione, il piano diventa un contratto e il runtime garantisce l'esecuzione. L'autonomia utile nasce quando il ragionamento probabilistico incontra un'infrastruttura deterministica.

Riferimenti tecnici

giovedì 8 ottobre 2026

SqlClient Pool V2: internals, benchmark e ThreadPool starvation con .NET 10

Deep dive • C# / .NET 10 • SQL Server / Azure SQL
Verifica delle fonti: 8 ottobre 2026. Implementazione analizzata: tag v7.1.0 di dotnet/SqlClient.

Il connection pool è uno dei componenti più sottovalutati di un'applicazione .NET. Finché le connessioni sono disponibili, sembra una cache quasi invisibile. Quando un processo parte, una nuova replica riceve traffico o molte richieste arrivano insieme, diventa invece un sistema di coordinamento: decide chi può riutilizzare una sessione, chi può crearne una e chi deve aspettare.

SqlClient Pool V2 cambia soprattutto il modo in cui il pool cresce e gestisce l'attesa. Per valutarlo seriamente dobbiamo separare quattro fenomeni: apertura fisica, checkout di una connessione già esistente, saturazione delle sessioni disponibili e disponibilità dei worker del runtime. Un unico numero di throughput non descrive questi quattro comportamenti.

Dalla cache di connessioni al sistema di ammissione

Una SqlConnection è l'oggetto logico usato dall'applicazione; la sessione fisica sottostante può sopravvivere a quel singolo oggetto e servire molte operazioni successive. L'apertura fisica comporta rete, negoziazione e autenticazione. Il checkout tenta invece di assegnare una risorsa già disponibile. Chiamare OpenAsync() non significa necessariamente aprire una nuova sessione sul server.

Il pool appartiene al processo. La chiave dipende dalla stringa di connessione e può includere identità, credenziali e configurazione dei token. Stringhe con ordine diverso delle keyword possono produrre pool distinti. Perciò Max Pool Size=100 non è un limite complessivo dell'applicazione distribuita. Dieci processi con tre pool ciascuno hanno un budget teorico di 3.000 sessioni. [3]

Questo suggerisce una prima scelta architetturale: centralizzare la costruzione delle stringhe e mantenere stabile la configurazione di autenticazione. Inserire un identificatore di richiesta in Application Name per migliorare il tracing può distruggere il riuso. L'identificatore della singola richiesta appartiene alla telemetria, non alla configurazione del pool.

Che cosa introduce davvero Pool V2

Microsoft ha presentato Pool V2 il 6 ottobre 2026. È disponibile da Microsoft.Data.SqlClient 7.1.0 e va attivato esplicitamente. La creazione di nuove connessioni può procedere in parallelo; il coordinamento usa Channels. Resta però una limitazione decisiva: alcuni passaggi di rete dell'apertura fisica sono sincroni e, nel percorso asincrono, vengono eseguiti da worker del ThreadPool. [1]

La firma asincrona dell'API e l'asincronia completa della sua implementazione sono proprietà diverse. La prima permette al chiamante di attendere senza bloccare il proprio flusso; la seconda determina quante risorse occupa il driver mentre lavora.

Attivazione e rollback

Impostare lo switch prima di utilizzare SqlClient: i valori vengono memorizzati internamente. In ASP.NET Core posizionarlo prima della creazione del builder. Per un confronto A/B usare processi separati; per il rollback modificare il valore e riavviare. Lo switch non è una keyword della connection string. [2]

AppContext.SetSwitch(
    "Switch.Microsoft.Data.SqlClient.UseConnectionPoolV2", true);

var builder = WebApplication.CreateBuilder(args);
// Registrazione DbContext, servizi e applicazione...

Non serve riscrivere le query. Con EF Core o Dapper, verificare però il pacchetto SqlClient effettivamente risolto: una dipendenza transitiva precedente alla 7.1 non acquisisce questa funzionalità perché il progetto compila per .NET 10. DbContext pooling e connection pooling sono inoltre meccanismi diversi: riutilizzare un contesto non equivale a conservare permanentemente la sua connessione SQL.

Dentro WaitHandleDbConnectionPool

Nel tag analizzato, WaitHandleDbConnectionPool coordina disponibilità, errore e creazione con handle distinti. PoolWaitHandles costruisce il semaforo delle risorse e un CreationSemaphore con capacità uno. TryGetConnection usa WaitHandle.WaitAny: una connessione restituita può sbloccare il chiamante; in alternativa il permesso di creazione consente un nuovo login.

La capacità uno serializza la creazione all'interno del pool. Non serializza tutte le query dell'applicazione e non impedisce di usare contemporaneamente sessioni già assegnate.

Gli open asincroni non risolti subito vengono rappresentati da PendingGetConnection, con owner, scadenza e completion. WaitForPendingOpen drena la coda; un guard con Interlocked evita più consumatori attivi. Il driver avvia un Thread di background per questo lavoro: il commento del metodo specifica che può bloccare a lungo e non deve essere un thread del pool gestito. Questa soluzione separa il chiamante dall'attesa, ma conserva un coordinamento seriale. [5]

Dentro ChannelDbConnectionPool

ChannelDbConnectionPool ha un fast path, TryGetPooledConnectionInline, che può restituire una connessione senza dispatch sul ThreadPool. Nel percorso lento, GetInternalConnection cerca prima una risorsa transazionale compatibile, poi una idle; se necessario tenta OpenNewInternalConnection. Se non ottiene una risorsa, attende sul channel.

Il percorso async usa ReadAsync; quello sync usa ReadChannelSyncOverAsync e blocca il chiamante. La fairness descritta dal codice riguarda i lettori in attesa, non una garanzia che tutte le chiamate applicative terminino nell'ordine di arrivo: fast path, nuove aperture, cancellazioni e scheduling interferiscono con quell'ordine.

Per gli open async non risolti inline, TryGetConnection avvia un Task.Run. Nel percorso di creazione, ConnectionFactory.CreatePooledConnection resta sincrono. Il codice controlla il timeout prima della chiamata, ma la factory non riceve quel cancellation token. Una cancellazione del chiamante non dimostra quindi che ogni lavoro fisico sia già terminato. [4]

Le invarianti che rendono possibile la concorrenza

La lettura ingegneristica del percorso è questa: prima di avviare un login bisogna riservare capacità; in caso di fallimento bisogna liberarla; dopo un clear bisogna evitare che risorse obsolete tornino disponibili; una risorsa restituita deve svegliare chi può usarla. Queste condizioni contano più del tipo di coda scelto.

Nel codice osservato il tracking passa da ConnectionPoolSlots, mentre IdleConnectionChannel incapsula il flusso delle risorse disponibili. La generazione del clear permette di riconoscere connessioni precedenti al cambio di generazione. Il channel può ricevere anche segnali null per far ritentare un'acquisizione: un risveglio non implica necessariamente una connessione pronta. L'eventuale ConcurrencyLimiter interno viene iniettato nel costruttore; il suo valore predefinito è assente. Non è un'impostazione pubblica da aggiungere arbitrariamente alla connection string. Queste sono osservazioni sul tag analizzato, non un contratto delle API pubbliche. [4]

Conseguenza pratica. Un'applicazione può usare un proprio limite di concorrenza anche quando il pool ha capacità residua. Il numero di sessioni consentite, il numero di login contemporanei e il numero di operazioni SQL ammesse sono tre budget diversi.

Un modello quantitativo: crescita, occupazione e code

I modelli seguenti sono strumenti di ragionamento, non misure del driver. Se servono N sessioni nuove e ogni apertura fisica dura mediamente L, una crescita completamente seriale ha un costo indicativo N × L. Con C aperture concorrenti, il termine ideale diventa circa ceil(N/C) × L. Questo limite ideale trascura autenticazione, scheduling, contesa sul server e distribuzione delle latenze.

Parallelizzare sposta il collo di bottiglia: il sistema può passare dall'attesa del permesso di creazione all'attesa di worker, autenticazione o risorse SQL. La presenza di 100 slot liberi nel pool non garantisce che 100 login simultanei siano la scelta migliore.

Per il regime stabile, la legge di Little suggerisce un'altra relazione: sessioni occupate medie ≈ operazioni al secondo × durata media del possesso. A 800 operazioni/s e 40 ms di possesso servono in media circa 32 sessioni; a 250 ms diventano 200. Sono esempi matematici, non risultati di benchmark. La coda richiede ulteriore margine e dipende da burst, variabilità e distribuzione delle durate.

Il possesso include tutto ciò che accade tra checkout e restituzione: lettura, mapping, transazione, eventuali chiamate HTTP e attese applicative. Ridurre una chiamata esterna fatta mentre si tiene aperta una connessione può migliorare la capacità più di un aumento del pool.

I benchmark Microsoft: che cosa possiamo concludere

Cold start Linux, 100 chiamantiPool legacyPool V2Rapporto
Open sincrono630,1 ms117,5 ms5,4×
Open asincrono604,1 ms92,0 ms6,6×

Sono risultati pubblicati da Microsoft, non misure eseguite per questo articolo. Misurano il completamento dell'intera rampa, con connessioni trattenute fino alla conclusione di tutte le aperture. Il confronto usa runtime .NET 9.0.19, SQL Server locale alla VM e Max Pool Size=200. Il laboratorio seguente usa .NET 10 e un harness diverso: non è una replica numerica di quei risultati. [1] [6]

La suite ufficiale include anche riuso immediato, query in regime stabile, possesso variabile e open sincroni sul ThreadPool con pool saturo. Per confrontare le implementazioni usa processi distinti. Questi scenari aiutano a verificare regressioni oltre il cold start. [6]

La domanda corretta per la propria applicazione è: quale componente della latenza rappresenta l'apertura fisica? Se il 95% del tempo è query e trasferimento dati, accelerare il restante 5% di 6,6 volte limita il miglioramento ideale complessivo a circa 1,04 volte. Questa applicazione della legge di Amdahl impedisce di trasformare un benchmark di startup in una promessa sul throughput di tutte le query.

Laboratorio riproducibile con C# e .NET 10

Il laboratorio usa un progetto console, SqlClient 7.1.0 fissato e SQL Server raggiungibile. Non richiede uno schema: esegue SELECT 1. Si può usare un'istanza Developer locale o un database di test in Azure SQL. Usare lo stesso endpoint e la stessa autenticazione nelle due varianti.

Stato della verifica. Il codice è fornito integralmente per riprodurre l'esperimento. È stato controllato staticamente, ma non compilato né eseguito nell'ambiente di preparazione dell'articolo, che non dispone di SDK .NET né di un endpoint SQL configurato. I risultati del laboratorio devono essere prodotti sul proprio ambiente; nessuna tabella seguente contiene misure locali inventate.

1. Progetto

dotnet new console -n PoolV2Lab -f net10.0
cd PoolV2Lab

Sostituire PoolV2Lab.csproj con:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.Data.SqlClient" Version="7.1.0" />
  </ItemGroup>
</Project>

Eseguire dotnet restore, conservare packages.lock.json e usare dotnet restore --locked-mode nei successivi ambienti. Registrare dotnet --info e dotnet list package --include-transitive. Per fissare anche l'SDK, creare un global.json con la versione realmente installata tramite dotnet new globaljson --sdk-version VERSIONE_INSTALLATA --roll-forward disable.

2. Program.cs completo

Parametri: implementazione v1|v2, scenario cold|hot|steady, chiamanti, dimensione massima pool, iterazioni, possesso simulato in millisecondi, API async|sync, minimo worker opzionale, attesa iniziale opzionale per collegare gli strumenti.

using System.Collections.Concurrent;
using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices;
using Microsoft.Data.SqlClient;

if (args.Length < 7)
    throw new ArgumentException(
        "v1|v2 cold|hot|steady callers pool iterations holdMs async|sync [minWorkers] [attachSeconds]");
bool v2 = args[0] switch {
    "v1" => false, "v2" => true, _ => throw new ArgumentException("pool version")
};
AppContext.SetSwitch("Switch.Microsoft.Data.SqlClient.UseConnectionPoolV2", v2);
string scenario = args[1];
int n = int.Parse(args[2]), max = int.Parse(args[3]);
int iterations = int.Parse(args[4]), holdMs = int.Parse(args[5]);
bool sync = args[6] switch {
    "sync" => true, "async" => false, _ => throw new ArgumentException("API mode")
};
if (scenario is not ("cold" or "hot" or "steady") ||
    n < 1 || max < 1 || iterations < 1 || holdMs < 0)
    throw new ArgumentException("Invalid scenario or numeric parameters");
if (scenario == "cold" && n > max)
    throw new ArgumentException("Cold ramp holds every connection: callers must be <= pool");
if (args.Length > 7) {
    ThreadPool.GetMinThreads(out _, out int ioMin);
    if (!ThreadPool.SetMinThreads(int.Parse(args[7]), ioMin))
        throw new ArgumentException("Invalid minWorkers");
}
string input = Environment.GetEnvironmentVariable("POOLLAB_CS")
    ?? throw new InvalidOperationException("Set POOLLAB_CS");
string cs = new SqlConnectionStringBuilder(input) {
    Pooling = true, MinPoolSize = 0, MaxPoolSize = max,
    ConnectTimeout = 30, ApplicationName = "NicoGIS.PoolV2Lab"
}.ConnectionString;
var rows = new ConcurrentQueue<Sample>();
string runId = Guid.NewGuid().ToString("N");
ThreadPool.GetMinThreads(out int workers, out _);
Console.WriteLine($"PID={Environment.ProcessId} run={runId} poolV2={v2} " +
    $"scenario={scenario} api={(sync ? "sync" : "async")} callers={n} pool={max} " +
    $"iterations={iterations} holdMs={holdMs} minWorkers={workers}");
Console.WriteLine($"runtime={RuntimeInformation.FrameworkDescription} " +
    $"os={RuntimeInformation.OSDescription} cpu={Environment.ProcessorCount} " +
    $"driver={typeof(SqlConnection).Assembly.GetName().Version}");
if (args.Length > 8) await Task.Delay(TimeSpan.FromSeconds(int.Parse(args[8])));

// Preflight includes JIT/authentication setup; then cold tests clear the pool.
await using (var probe = new SqlConnection(cs)) {
    await probe.OpenAsync();
    await using var cmd = new SqlCommand("SELECT 1", probe);
    await cmd.ExecuteScalarAsync();
}
SqlConnection.ClearAllPools();

// Open and hold all requested connections. Dispose only after all attempts settle.
async Task Ramp(int count, int iteration, bool record) {
    var gate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
    long releaseTicks = 0;
    async Task<SqlConnection> OpenOne() {
        await gate.Task;
        return await OpenAfterGate();
    }
    async Task<SqlConnection> OpenAfterGate() {
        var c = new SqlConnection(cs);
        long begin = Stopwatch.GetTimestamp();
        try {
            if (sync) c.Open(); else await c.OpenAsync();
            if (record) rows.Enqueue(new(iteration, true,
                Ms(begin), Ms(releaseTicks), ""));
            return c;
        } catch (Exception ex) {
            c.Dispose();
            if (record) rows.Enqueue(new(iteration, false,
                Ms(begin), Ms(releaseTicks), ex.GetType().Name));
            throw;
        }
    }
    Task<SqlConnection>[] tasks = Enumerable.Range(0, count).Select(_ => sync
        ? Task.Factory.StartNew(() => {
            gate.Task.GetAwaiter().GetResult();
            return OpenAfterGate().GetAwaiter().GetResult();
        }, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default)
        : OpenOne()).ToArray();
    releaseTicks = Stopwatch.GetTimestamp();
    gate.SetResult(true);
    try { await Task.WhenAll(tasks); }
    finally {
        foreach (var t in tasks)
            if (t.IsCompletedSuccessfully) t.Result.Dispose();
    }
}

bool rampFailed = false;
if (scenario != "cold") await Ramp(max, -1, false);
long start = Stopwatch.GetTimestamp();
if (scenario == "cold") {
    for (int i = 0; i < iterations; i++) {
        // Exclude clear from the per-ramp clock; total wall time still includes it.
        SqlConnection.ClearAllPools();
        long t = Stopwatch.GetTimestamp();
        try { await Ramp(n, i, true); }
        catch (Exception ex) { rampFailed = true; Console.WriteLine($"rampError={ex.GetType().Name}"); }
        Console.WriteLine(FormattableString.Invariant($"ramp={i} batchMs={Ms(t):F3}"));
        if (rampFailed) break; // Avoid benchmarking cached authentication failures.
    }
} else {
    var gate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
    Task[] tasks = Enumerable.Range(0, n).Select(_ => Task.Run(async () => {
        await gate.Task;
        for (int i = 0; i < iterations; i++) {
            long t = Stopwatch.GetTimestamp();
            double openMs = double.NaN;
            bool ok = false;
            string error = "";
            try {
                // Includes disposal in totalMs because scope ends before finally.
                using var c = new SqlConnection(cs);
                if (sync) c.Open(); else await c.OpenAsync();
                openMs = Ms(t);
                if (scenario == "steady") {
                    using var cmd = new SqlCommand("SELECT 1", c) { CommandTimeout = 30 };
                    if (sync) cmd.ExecuteScalar(); else await cmd.ExecuteScalarAsync();
                }
                if (holdMs > 0) {
                    if (sync) Thread.Sleep(holdMs); else await Task.Delay(holdMs);
                }
                ok = true;
            } catch (Exception ex) { error = ex.GetType().Name; }
            finally { rows.Enqueue(new(i, ok, openMs, Ms(t), error)); }
        }
    })).ToArray();
    gate.SetResult(true);
    await Task.WhenAll(tasks);
}
double wallMs = Ms(start);
Sample[] samples = rows.ToArray();
double[] opens = samples.Where(x => x.Ok && double.IsFinite(x.OpenMs))
    .Select(x => x.OpenMs).Order().ToArray();
double[] totals = samples.Where(x => x.Ok).Select(x => x.TotalMs).Order().ToArray();
double P(double[] values, double p) => values.Length == 0 ? double.NaN :
    values[Math.Clamp((int)Math.Ceiling(p * values.Length) - 1, 0, values.Length - 1)];
Console.WriteLine(FormattableString.Invariant(
    $"ok={samples.Count(x => x.Ok)} errors={samples.Count(x => !x.Ok)} wallMs={wallMs:F3} successPerSecond={samples.Count(x => x.Ok) * 1000 / wallMs:F2} openP50={P(opens,.50):F3} openP95={P(opens,.95):F3} openP99={P(opens,.99):F3} totalP95={P(totals,.95):F3}"));
string file = $"samples-{args[0]}-{scenario}-{args[6]}-{runId}.csv";
using (var writer = new StreamWriter(file)) {
    writer.WriteLine("iteration,ok,open_ms,total_ms,error");
    foreach (var s in samples)
        writer.WriteLine(string.Join(",", s.Iteration, s.Ok ? 1 : 0,
            s.OpenMs.ToString("F6", CultureInfo.InvariantCulture),
            s.TotalMs.ToString("F6", CultureInfo.InvariantCulture), s.Error));
}
Console.WriteLine($"csv={file}");
if (samples.Any(x => !x.Ok) || rampFailed) Environment.ExitCode = 2;
double Ms(long ticks) => Stopwatch.GetElapsedTime(ticks).TotalMilliseconds;
record Sample(int Iteration, bool Ok, double OpenMs, double TotalMs, string Error);

3. Configurazione ed esecuzione

PowerShell, con credenziali di un database di laboratorio:

$env:POOLLAB_CS = 'Server=localhost;Database=master;Integrated Security=true;Encrypt=true;TrustServerCertificate=true'
dotnet restore
dotnet build -c Release

# Rampa fredda async: 100 sessioni trattenute, limite pool 200.
dotnet bin/Release/net10.0/PoolV2Lab.dll v1 cold 100 200 10 0 async
dotnet bin/Release/net10.0/PoolV2Lab.dll v2 cold 100 200 10 0 async

# Rampa fredda sync: thread dedicati, separata dall'esperimento starvation.
dotnet bin/Release/net10.0/PoolV2Lab.dll v1 cold 100 200 10 0 sync
dotnet bin/Release/net10.0/PoolV2Lab.dll v2 cold 100 200 10 0 sync

# Riuso senza query e senza possesso artificiale.
dotnet bin/Release/net10.0/PoolV2Lab.dll v1 hot 50 100 1000 0 async
dotnet bin/Release/net10.0/PoolV2Lab.dll v2 hot 50 100 1000 0 async

# Regime stabile: più chiamanti che sessioni, SELECT 1 e possesso di 20 ms.
dotnet bin/Release/net10.0/PoolV2Lab.dll v1 steady 100 20 100 20 async
dotnet bin/Release/net10.0/PoolV2Lab.dll v2 steady 100 20 100 20 async

TrustServerCertificate=true è qui una concessione al certificato del laboratorio locale. Con Azure SQL usare il nome completo dell'endpoint e la verifica del certificato, quindi TrustServerCertificate=false. Con SQL authentication sostituire l'identità integrata con le credenziali di test. Non stampare la stringa nei risultati.

4. Che cosa misuriamo, esattamente

  • Cold: dopo il preflight si svuota il pool prima di ogni rampa. Ogni successo trattiene la connessione fino a quando tutti i tentativi sono conclusi. Ciò forza N aperture fisiche invece del riuso accidentale di poche sessioni. callers deve essere minore o uguale a pool.
  • Hot: il warmup apre e trattiene contemporaneamente Max Pool Size sessioni, poi le restituisce. Aprire e chiudere serialmente cento volte non garantirebbe cento sessioni fisiche. La fase misurata non esegue query.
  • Steady: il pool è caldo; ogni worker ripete checkout, SELECT 1, possesso simulato e dispose. Il numero dei chiamanti può superare gli slot.

open_ms è il tempo osservato attorno all'apertura logica; può includere attesa del pool, lavoro del driver e scheduling. Non isola il costo puro del channel. Nel cold, total_ms arriva dal rilascio del gate al completamento dell'open del singolo chiamante; non include il dispose collettivo. batchMs misura invece la rampa fino alla restituzione di tutte le sessioni. Negli scenari hot e steady, total_ms va dall'inizio dell'operazione al termine del dispose.

successPerSecond è utile in hot e steady. Nel cold il wall time globale include i clear tra rampe: confrontare soprattutto la distribuzione dei batchMs, che li esclude. Il preflight rende freddo il pool, non DNS, JIT, provider di autenticazione o processo. Per misurare la readiness reale dell'applicazione occorre anche un esperimento senza preflight, da processo nuovo.

I percentili mostrati sono nearest-rank sui soli successi. Gli errori restano nel CSV e nel conteggio: un run con errori non va dichiarato più veloce perché ha meno campioni lenti. Con 100 osservazioni p99 è una statistica molto fragile. Per le rampe raccogliere più processi e riportare mediana e variabilità tra processi.

Un protocollo di benchmark che resiste alle conclusioni facili

Questo harness è un esperimento di carico controllato, non un microbenchmark isolato con BenchmarkDotNet. Usa allocazioni, timestamp e raccolta di campioni; tali costi fanno parte della misura. Sono presenti in entrambe le varianti, ma possono pesare molto nel test hot. Per differenze di pochi microsecondi affiancare la suite ufficiale e strumenti di profiling.

  1. Fissare driver, SDK, runtime, architettura, GC, endpoint, autenticazione e SNI. Non modificare altri switch mentre si confronta V1 con V2.
  2. Eseguire almeno cinque processi per variante, alternando l'ordine V1/V2 e poi V2/V1. Evitare che la seconda variante benefici sempre dello stato del server lasciato dalla prima.
  3. Separare chiamanti 10, 25, 50 e 100; poi pool inferiori, uguali e superiori alla domanda. Un run diverso per ciascuna combinazione.
  4. Registrare CPU e memoria di client e server, runtime counters, errori e latenza. Conservare i CSV e l'output con tutti i parametri.
  5. Ripetere con rete locale e con l'endpoint reale. Se si introduce ritardo con un proxy o traffic shaping, documentare direzione, jitter e perdita: un RTT aggiunto non è un ritardo uniforme di autenticazione.
  6. Separare sessioni appena create da riuso caldo. Non usare ClearAllPools a intervalli nel test steady: si trasformerebbe il workload in churn.
ScenarioIndicatore principaleDomanda
Cold rampMediana e dispersione batchMsQuanto rapidamente otteniamo N sessioni?
HotOperazioni/s e open p95Il nuovo coordinamento introduce overhead nel riuso?
Steady saturoTotal p95/p99, errori, throughputCome cresce la coda quando la capacità non basta?
Rete lenta e pool freddoBatchMs, worker e queue lengthLa crescita parallela aumenta pressione sui thread?
Sync sul ThreadPoolStack, worker, latenza del probeI chiamanti bloccanti ritardano lavoro non SQL?

Il limite dei worker a ciclo chiuso

In steady ogni worker avvia l'operazione successiva dopo la precedente. Il modello è a ciclo chiuso: quando il sistema rallenta, rallenta anche l'offerta di nuove operazioni. Non misura quindi la latenza di una coda alimentata da un tasso esterno costante e non elimina il problema della coordinated omission.

Per la verifica di uno SLO HTTP aggiungere un generatore a tasso di arrivo controllato su un endpoint reale, in un processo separato. Registrare il momento di arrivo previsto, l'arrivo effettivo e la risposta. Sotto overload, stabilire prima una policy di rifiuto o scadenza: nessun pool può rendere stabile una coda con domanda permanentemente superiore alla capacità di servizio.

ThreadPool starvation: il rischio da misurare

La starvation si verifica quando il runtime non ha worker disponibili per eseguire lavoro pronto. La CPU può restare bassa perché molti thread aspettano. La crescita del numero di thread, un backlog persistente e stack bloccati sono indizi da leggere insieme; un singolo contatore elevato non è una diagnosi. Le euristiche moderne riducono alcuni effetti del blocking, ma non rendono gratuito quel blocking. [7]

Per Pool V2 formuliamo un'ipotesi sperimentale precisa: durante la crescita del pool con rete lenta, molti login fisici possono occupare worker simultaneamente; le continuation applicative competono per gli stessi worker. Il completamento della rampa può migliorare rispetto alla serializzazione e, contemporaneamente, la latenza di lavoro estraneo a SQL può peggiorare. È una possibilità da verificare, non un esito universale.

Due esperimenti diversi, due cause diverse

Esperimento A — limite del driver. Usare cold ... async con endpoint ad alta latenza e minimo worker invariato. Confrontare worker, coda e batch time delle due implementazioni. Per renderlo osservabile raccogliere la trace dall'avvio o aumentare le rampe; il burst locale potrebbe finire prima del primo campionamento.

Esperimento B — blocking applicativo. Usare steady ... sync. Qui Task.Run esegue Open(), query sincrona e Thread.Sleep: il blocking viene introdotto intenzionalmente dal laboratorio. Non attribuire al driver tutti i thread bloccati osservati in questo scenario. Il test è utile proprio per riconoscere la differenza negli stack.

# 30 secondi prima del test per collegare i tool al PID stampato.
dotnet bin/Release/net10.0/PoolV2Lab.dll v2 steady 100 10 100 50 sync 4 30

# Controlli: stessa configurazione, minimo worker diverso o API async.
dotnet bin/Release/net10.0/PoolV2Lab.dll v2 steady 100 10 100 50 sync 32 30
dotnet bin/Release/net10.0/PoolV2Lab.dll v2 steady 100 10 100 50 async 4 30

# Ripetere ogni configurazione anche con v1.

SetMinThreads(4, ...) non impone quattro thread massimi e non precrea necessariamente tutti i thread richiesti. Modifica una soglia del runtime. Conservare anche un run senza override; ripetere in un processo nuovo per evitare che un esperimento precedente abbia già fatto crescere il ThreadPool.

Strumenti e segnali

dotnet tool install --global dotnet-counters
dotnet tool install --global dotnet-stack
dotnet tool install --global dotnet-trace

# Sostituire 12345 con il PID stampato dal laboratorio.
dotnet-counters monitor --process-id 12345 --counters System.Runtime
dotnet-stack report --process-id 12345
dotnet-trace collect --process-id 12345 --duration 00:00:30

Rilevare anche le versioni dei tool. La trace generica è un punto di partenza: per attribuire precisamente il tempo di attesa usare la raccolta degli eventi di wait documentata nel tutorial Microsoft e correlarla agli stack. Alcuni frame nativi potrebbero richiedere simboli e un profiler adatto alla piattaforma. [7]

Metrica System.RuntimeLettura operativa
dotnet.thread_pool.thread.countQuantità di worker; osservare la traiettoria nel tempo.
dotnet.thread_pool.queue.lengthLavoro in coda; correlarlo con completamenti e latenza.
dotnet.thread_pool.work_item.countContatore cumulativo del lavoro completato: calcolare il tasso su un intervallo.
dotnet.process.cpu.timeTempo CPU cumulativo: usare una derivata temporale per stimare utilizzo.
dotnet.gc.heap.total_allocatedAllocazioni cumulative: osservare il tasso, non soltanto il valore assoluto.

Le metriche del Meter System.Runtime non vanno confuse con i nomi dei vecchi EventCounters. La versione dello strumento può presentare etichette e visualizzazioni differenti. [8]

Un probe opzionale utile è un timer che misura il ritardo della propria continuation, oppure un endpoint che restituisce immediatamente senza accedere a SQL. Se cresce anche la sua latenza durante le rampe, il problema ha una dimensione di scheduling condiviso. Separare comunque la CPU del generatore da quella dell'applicazione, altrimenti il probe misura anche il proprio ambiente di carico.

Backpressure applicativa: limitare prima del checkout

Un limite asincrono può impedire che un burst ammetta contemporaneamente troppe operazioni SQL. Deve coprire tutto il periodo di possesso e precedere l'apertura: acquisire il gate dopo OpenAsync consumerebbe già una sessione. Ecco un pattern indipendente dagli interni del driver:

// Un'unica istanza condivisa per il budget che si vuole applicare.
private static readonly SemaphoreSlim DbGate = new(32, 32);

public static async Task<object?> QueryAsync(string cs, CancellationToken ct)
{
    // Budget per l'ammissione, separato dal timeout SQL.
    if (!await DbGate.WaitAsync(TimeSpan.FromSeconds(2), ct))
        throw new TimeoutException("Budget di ammissione SQL esaurito");
    try
    {
        await using var connection = new SqlConnection(cs);
        await connection.OpenAsync(ct);
        await using var command = new SqlCommand("SELECT 1", connection)
        {
            CommandTimeout = 10
        };
        return await command.ExecuteScalarAsync(ct);
    }
    finally
    {
        DbGate.Release();
    }
}

Trentadue è un valore illustrativo, da scegliere con misure. Il semaforo limita le operazioni ammesse ma non limita da solo il numero dei chiamanti in attesa. Il timeout ne limita la permanenza; una policy con queue limit e rifiuto esplicito controlla anche la cardinalità della coda. Su un servizio multi-tenant decidere se il budget debba essere globale, per tenant o gerarchico, per evitare che un cliente monopolizzi l'ammissione.

Lo stesso criterio vale per l'aumento dei minimi del ThreadPool: testare valori diversi e misurare il beneficio. Più worker possono ridurre il ritardo di iniezione e aumentare pressione su memoria, scheduler e database. Un risultato migliore sul cold start deve essere confermato anche su p99 e throughput stabile.

Timeout, cancellazione e resilienza non sono sinonimi

Distinguere timeout di ammissione, tempo dell'open, timeout del comando e deadline dell'intera richiesta. In SqlClient 7.1 lo switch UseOverallConnectTimeoutForPoolWait permette di condividere il budget tra attesa del pool e connessione di rete; il default resta false. Non attivarlo solo nella variante V2 di un benchmark A/B, altrimenti si cambiano due fattori. [2]

La cancellazione è anche un protocollo di pulizia. Testare che una richiesta annullata non perda capacità, che una risorsa arrivata dopo l'annullamento venga restituita e che il sistema recuperi dopo un timeout. Un errore rapido non è necessariamente un timeout reale: clear e shutdown richiedono diagnosi distinta.

Il repository contiene segnalazioni separate per la classificazione degli errori durante ClearPool/ClearAllPools. L'issue #4737 riguarda il pool legacy nella 7.1.0; #4719 richiede una verifica specifica per il channel pool senza un repro confermato al momento della descrizione. Non generalizzare una segnalazione all'altra implementazione. Questi collegamenti servono a orientare i test, non a certificare lo stato di ogni patch successiva. [9]

Per la prova di resilienza trattenere tutti gli slot, avviare un waiter, poi eseguire separatamente cancellazione, scadenza e clear. Ripetere con entrambe le API. Infine eseguire una nuova operazione e controllare il recupero. Tenere queste prove fuori dal benchmark prestazionale.

Azure SQL e scale-out: il budget è distribuito

Questa è una deduzione architetturale dal modello: ogni processo che scala parte con un proprio pool. Se otto repliche cercano contemporaneamente cento sessioni, la domanda aggregata è fino a ottocento aperture. Parallelizzare la crescita del singolo processo può comprimere quel lavoro in una finestra più breve e aumentare il picco visto da autenticazione e server.

Perciò bisogna misurare startup e scale-out insieme. Un warmup moderato può ridurre la latenza delle prime richieste, ma uno coordinato male può sincronizzare i login. Jitter, limiti di ammissione e retry con budget complessivo rendono la domanda più controllabile. Queste sono scelte progettuali da verificare sulla propria topologia.

Un pool rapido non corregge una transazione mantenuta aperta mentre un agente LLM chiama un servizio esterno. In un orchestratore C#/Azure, leggere i dati, materializzare il risultato e restituire la connessione prima dell'inferenza è spesso la separazione più efficace. Se serve consistenza tra più fasi, progettare esplicitamente versioni, idempotenza o compensazioni: trattenere una sessione durante un'attesa non la crea gratuitamente.

Una decisione basata sui percorsi reali dell'applicazione

Il valore di Pool V2 si valuta osservando quali code scompaiono e quali emergono. Per una dashboard Blazor che apre molte sessioni al primo accesso, la rampa può pesare. Per un worker con transazioni lunghe, può dominare la durata del possesso. Per un'applicazione multi-tenant, può dominare la frammentazione dei pool. Il laboratorio deve riflettere il percorso che produce la latenza dell'utente.

Una sperimentazione utile conserva gli stessi binari e abilita soltanto lo switch su una quota di istanze. Confronta startup, errori, open p95/p99, occupazione e scheduling, mantenendo pronto il rollback con riavvio. Il criterio di accettazione deve includere la latenza di operazioni non SQL e la stabilità del server, oltre al tempo della rampa.

Il connection pool è parte del sistema di controllo della concorrenza. Con Pool V2 abbiamo un nuovo modo di coordinare le risorse e creare sessioni; il lavoro dell'architetto è mantenere coerenti budget di connessioni, login, worker e operazioni ammesse. È in questa coerenza che un miglioramento del driver diventa un miglioramento dell'applicazione.

Fonti e codice verificabile

  1. Microsoft — Try SqlClient’s new connection pool for faster parallel connections, 6 ottobre 2026. Annuncio, benchmark cold start e limite del percorso di rete async.
  2. Microsoft Learn — AppContext switches in SqlClient. Attivazione, caching degli switch e budget complessivo del connect timeout.
  3. Microsoft Learn — SQL Server connection pooling. Identità dei pool e controlli pubblici.
  4. ChannelDbConnectionPool.cs — tag v7.1.0. Analisi effettuata sul file raw dello stesso tag; il branch main può essere diverso.
  5. WaitHandleDbConnectionPool.cs — tag v7.1.0. Semafori, WaitAny e pending opens.
  6. SqlClient — Connection Pool V2 Performance Results. Metodologia e copertura dei benchmark ufficiali; pagina aggiornabile.
  7. Microsoft Learn — Debug ThreadPool starvation. Diagnosi, strumenti e stack.
  8. Microsoft Learn — .NET runtime metrics. Meter System.Runtime e significato delle metriche.
  9. SqlClient #4737 — segnalazione legacy pool e #4719 — verifica channel pool. Issue di tracking, da ricontrollare per la patch scelta.

martedì 6 ottobre 2026

ArcGIS Crime Analysis 6.0: dalla mappa dei reati alla Spatio-Temporal Intelligence

Una mappa dei reati non è Crime Analysis.

Visualizzare migliaia di incidenti come punti su una mappa può essere utile, ma il vero problema analitico è molto più profondo: capire se ciò che osserviamo rappresenta un pattern statisticamente significativo, come evolve nel tempo, quanto rapidamente decade il rischio attorno a un evento, se un intervento ha realmente modificato il fenomeno e, soprattutto, quanta incertezza esiste nei dati che stiamo analizzando.

Con Crime Analysis 6.0, rilasciato a settembre 2026, Esri ha aggiornato in modo significativo la propria soluzione per ArcGIS Pro. La nuova release supporta ArcGIS Pro 3.7, introduce un nuovo add-in e due strumenti particolarmente interessanti: Aoristic Analysis e Weighted Displacement Difference.

Ma questi strumenti diventano ancora più interessanti se li inseriamo in un quadro più ampio:

Crime Mapping
      ↓
Spatial Statistics
      ↓
Spatio-Temporal Analysis
      ↓
Crime Pattern Detection
      ↓
Uncertainty Modelling
      ↓
Vector + Graph Representation
      ↓
GeoAI / Agentic Spatial Intelligence

In questo deep dive vediamo quindi Crime Analysis non semplicemente come una toolbar di ArcGIS Pro, ma come il punto di ingresso verso una moderna architettura di spatial intelligence.

1. Il dato criminale è un evento spazio-temporale

Il modello più semplice rappresenta un incidente come:

CrimeEvent = (x, y, t)

dove x,y identificano la posizione e t il tempo.

Nella realtà un evento è molto più ricco:

CrimeEvent =
{
    Geometry,
    OccurFrom,
    OccurTo,
    CrimeType,
    ModusOperandi,
    Target,
    Context,
    Relationships
}

Già qui emerge un problema fondamentale: spesso t non è conosciuto.

Un furto in abitazione può essere scoperto alle 18:00, mentre il proprietario ha lasciato casa alle 08:00. L'unica informazione corretta è quindi:

08:00 ≤ t ≤ 18:00

Attribuire arbitrariamente il crimine alle 08:00, alle 13:00 o alle 18:00 introduce informazione che il dato originale non contiene.

Ed è esattamente qui che entra in gioco una delle novità più interessanti di Crime Analysis 6.0.

2. Aoristic Analysis: modellare il tempo che non conosciamo

L'Aoristic Analysis affronta gli eventi per i quali conosciamo un intervallo temporale ma non l'istante esatto.

Supponiamo che un evento possa essere avvenuto in quattro intervalli temporali:

10:00
11:00
12:00
13:00

In assenza di altra informazione, ciascun intervallo riceve peso:

P(t) = 1 / 4 = 0.25

Più generalmente, per un evento i compatibile con n intervalli:

w(i,t) = 1 / n

quando il time bucket t appartiene alla finestra temporale dell'evento, e zero altrimenti.

Aggregando molti eventi otteniamo:

A(t) = Σ w(i,t)

che rappresenta il peso aoristico complessivo associato a quel periodo.

La differenza concettuale è importante: non stiamo inventando l'orario più probabile del singolo evento. Stiamo propagando esplicitamente la sua incertezza temporale.

3. Implementarlo in C#

Il principio può essere riprodotto facilmente in .NET. Immaginiamo un modello minimale:

public sealed record CrimeIncident(
    long Id,
    DateTime OccurFrom,
    DateTime OccurTo,
    string CrimeType);

Possiamo calcolare quanto un evento contribuisce a un determinato bucket temporale:

static double AoristicWeight(
    DateTime occurFrom,
    DateTime occurTo,
    DateTime bucketFrom,
    DateTime bucketTo)
{
    if (occurTo <= occurFrom)
        return 0;

    var from = occurFrom > bucketFrom
        ? occurFrom
        : bucketFrom;

    var to = occurTo < bucketTo
        ? occurTo
        : bucketTo;

    if (to <= from)
        return 0;

    return (to - from).TotalSeconds /
           (occurTo - occurFrom).TotalSeconds;
}

Questa variante utilizza la frazione effettiva di sovrapposizione dell'intervallo, rendendo esplicito ciò che normalmente viene nascosto dietro una semplice classificazione temporale.

In un add-in ArcGIS Pro potremmo quindi leggere le feature con ArcGIS Pro SDK, estrarre OccurFrom e OccurTo, costruire i bucket e produrre una tabella o una feature class derivata.

Il punto interessante non è riscrivere uno strumento che ArcGIS possiede già. È capire il modello matematico per poterlo integrare in pipeline analitiche più complesse.

4. Kernel Density non significa Hot Spot Analysis

Una delle confusioni più comuni nella spatial analysis è interpretare qualsiasi superficie con zone rosse come un hot spot statisticamente significativo.

Kernel Density Estimation (KDE) costruisce una superficie continua distribuendo attorno agli eventi una funzione kernel. In forma semplificata:

f̂(x) = 1/(nh²) Σ K((x-Xᵢ)/h)

dove h è il bandwidth.

Aumentando il search radius si ottiene una superficie più liscia e generalizzata; riducendolo emergono strutture locali più dettagliate.

Il risultato rimane però una stima di densità, non automaticamente una prova di significatività statistica.

high density ≠ statistically significant hot spot

5. Getis-Ord Gi*: quando il clustering diventa statistico

Per individuare cluster statisticamente significativi ArcGIS Pro mette a disposizione, tra gli altri strumenti, Optimized Hot Spot Analysis, basato sulla statistica Getis-Ord Gi*.

Concettualmente non ci interessa soltanto sapere se una feature ha un valore elevato. Vogliamo sapere se essa è circondata da valori elevati in misura difficilmente compatibile con una distribuzione casuale.

Il risultato comprende:

Gi* z-score
p-value
confidence bin

Questa distinzione dovrebbe essere alla base di qualsiasi sistema serio di crime intelligence:

Visualization
    ≠
Density estimation
    ≠
Spatial clustering
    ≠
Statistical evidence

6. DBSCAN, HDBSCAN e clustering spazio-temporale

Non tutti i pattern hanno forma regolare.

Density-based Clustering in ArcGIS Pro consente di identificare cluster immersi nel rumore utilizzando algoritmi come DBSCAN, HDBSCAN e OPTICS.

Questa proprietà è particolarmente interessante nel crime analysis perché un pattern può essere molto evidente per tre settimane e scomparire completamente nel mese successivo.

Una distanza puramente spaziale:

d(i,j) = √((xᵢ-xⱼ)² + (yᵢ-yⱼ)²)

non cattura questa dinamica.

Possiamo concettualmente introdurre una distanza spazio-temporale:

D(i,j) = αDspace(i,j) + βDtime(i,j)

con scale opportunamente normalizzate.

7. Repeat e Near-Repeat: il rischio non è statico

Uno dei concetti più potenti della crime geography è la repeat/near-repeat victimization.

Dopo un evento, il luogo colpito e le aree vicine possono presentare per un periodo limitato un rischio maggiore di eventi analoghi. Tale influenza tende a diminuire sia con la distanza sia con il tempo.

Crime Analysis contiene strumenti dedicati per classificare eventi come originator, repeat e near-repeat utilizzando bande spaziali e temporali.

Possiamo rappresentare concettualmente il decadimento come:

R(d,t) = R₀ · e-λd · e-μt

dove:

d = distanza dall'incidente
t = tempo trascorso
λ = decadimento spaziale
μ = decadimento temporale

Non è quindi una superficie di rischio permanente.

È una superficie che decade continuamente nello spazio e nel tempo.

8. Prediction Zones: attenzione alla parola "prediction"

Crime Analysis può generare Prediction Zones a partire dai pattern repeat/near-repeat.

È però importante capire cosa significa "prediction".

Non significa:

qui avverrà il prossimo crimine

Significa piuttosto:

date le regolarità spazio-temporali
osservate nel dataset,
quest'area presenta un rischio relativo
più elevato nel breve periodo.

È una differenza enorme, sia statisticamente sia eticamente.

9. Space-Time Cube: quando la mappa 2D non basta più

Un altro salto concettuale consiste nel trattare lo spazio e il tempo come un'unica struttura analitica.

Lo Space-Time Cube suddivide i dati in bin spaziali e temporali:

             TIME
              ↑
              │  ▢
              │  ▢ ▢
              │  ▢ ▢
              │  ▢ ▢ ▢
              │
              └────────────→ SPACE

Emerging Hot Spot Analysis permette di osservare non solo dove sono presenti cluster, ma come essi evolvono nel tempo.

La domanda non è più:

Dove sono gli hot spot?

ma:

Dove stanno nascendo?
Quali persistono?
Quali si stanno intensificando?
Quali stanno scomparendo?

Questa è già spatio-temporal intelligence.

10. Crime series e comportamento spaziale

Crime Analysis include workflow per ordinare cronologicamente gli eventi appartenenti a una serie e rappresentarne le connessioni.

Questo consente di studiare concetti classici del geographic profiling, come la distinzione tra comportamento marauder e commuter.

La sequenza:

P1 → P2 → P3 → P4 → P5

diventa quindi molto più di una polilinea.

Possiamo analizzare:

distance(Pn, Pn+1)
bearing(Pn, Pn+1)
Δt(Pn, Pn+1)
velocity
dispersion
directional persistence

e cercare cambiamenti nella struttura della serie.

11. Weighted Displacement Difference: l'intervento ha funzionato?

Crime Analysis 6.0 introduce anche Weighted Displacement Difference.

Qui la domanda cambia completamente.

Non vogliamo più soltanto descrivere il fenomeno. Vogliamo valutare un intervento.

Response Area
      +
Response Displacement Zone

Control Area
      +
Control Displacement Zone

        ↓

Before vs After
        ↓
Weighted Displacement Difference

La statistica confronta il cambiamento degli incidenti nell'area interessata dall'intervento con quello osservato in un'area di controllo e considera anche le aree circostanti per verificare se il fenomeno sia stato semplicemente spostato.

Questo è un passaggio metodologico importantissimo:

Crime ↓ nell'area A

non implica necessariamente:

Intervento efficace

perché potrebbe essersi verificato:

A ↓
B ↑

ovvero displacement.

12. Da Crime Analysis a un Crime Graph

Fin qui abbiamo considerato principalmente geometrie, attributi e tempo.

Ma un'indagine contiene naturalmente anche relazioni.

Person ── called ── Person
   │                   │
 visited             owns
   │                   │
   ▼                   ▼
Place ── near ── CrimeEvent
                       │
                    similar
                       │
                       ▼
                  CrimeEvent

Crime Analysis contiene già strumenti investigativi per lavorare con call links, settori delle celle telefoniche e corrispondenze spazio-temporali.

La naturale evoluzione architetturale è quindi rappresentare parte del dominio come un spatio-temporal knowledge graph.

13. Il passo successivo: Crime Event Embeddings

Ed è qui che possiamo andare oltre gli strumenti standard.

Un incidente non deve necessariamente essere confrontato con un altro soltanto attraverso la distanza geografica.

Possiamo costruire una rappresentazione multidimensionale:

E = [
    spatial features,
    temporal features,
    modus operandi,
    target characteristics,
    environmental context,
    semantic description,
    graph relationships
]

e trasformarla in un vettore:

z = f(E) ∈ Rⁿ

A quel punto due incidenti possono essere confrontati attraverso una funzione di similarità che combina più domini:

D(i,j) = αDspace + βDtime + γDsemantic + δDMO + εDgraph

La query investigativa cambia radicalmente.

Da:

Find incidents within 500 meters.

a:

Find incidents that are:

geographically compatible
AND temporally compatible
AND behaviorally similar
AND semantically similar
AND connected through relevant entities.

Questo è il punto d'incontro tra GIS, vector search e graph analytics.

14. Un'architettura GeoAI

Una possibile architettura enterprise potrebbe diventare:

                ArcGIS Pro
                    │
             Crime Analysis
                    │
          Spatial Statistics
                    │
             ArcGIS Enterprise
                    │
        ┌───────────┴───────────┐
        │                       │
 Spatial Features         Operational Data
        │                       │
        └───────────┬───────────┘
                    │
               Azure SQL
                    │
       ┌────────────┼────────────┐
       │            │            │
   relational     vector       graph
     data         index      relationships
       │            │            │
       └────────────┼────────────┘
                    │
             Retrieval Layer
                    │
                 GeoAgent
                    │
             ArcGIS Pro / Web

Il GeoAgent potrebbe ricevere una richiesta come:

Analyze residential burglaries from the last six weeks.

Identify statistically significant spatial clusters,
test for repeat/near-repeat behavior,
compare modus-operandi similarity,
and explain which evidence supports each candidate series.

15. L'LLM non deve fare statistica al posto del GIS

Questo è forse il punto architetturale più importante.

Non costruirei un sistema nel quale chiediamo direttamente a un Large Language Model:

Where will the next crime occur?

Un LLM non dovrebbe sostituire Getis-Ord Gi*, KDE, DBSCAN, Space-Time Cube o un modello statistico validato.

Dovrebbe invece funzionare come orchestratore analitico:

User
  │
  ▼
GeoAgent
  │
  ├──► ArcGIS Spatial Statistics
  │
  ├──► Space-Time Analysis
  │
  ├──► Vector Search
  │
  ├──► Graph Query
  │
  └──► Operational Database
          │
          ▼
      Evidence
          │
          ▼
   LLM explanation

In altre parole:

LLM ≠ analytical truth

LLM = reasoning + orchestration + explanation

mentre il risultato quantitativo deve continuare a provenire da strumenti deterministici, modelli statistici o algoritmi esplicitamente validati.

16. Explainability prima della prediction

Un sistema di crime intelligence dovrebbe poter rispondere non soltanto:

Risk = HIGH

ma anche:

WHY?

• 7 recent related incidents
• statistically significant Gi* cluster
• 5 incidents inside the near-repeat temporal window
• high spatial proximity
• strong modus-operandi similarity
• pattern intensifying over the last 3 weeks

La spiegabilità non è un accessorio dell'AI.

In domini ad alto impatto è parte dell'architettura.

17. Il problema più difficile: i dati non sono la realtà

Esiste infine un limite che nessun algoritmo può eliminare automaticamente.

Un database criminale non rappresenta necessariamente:

all crimes that happened

ma più precisamente qualcosa di simile a:

events reported
+
events detected
+
events recorded
+
events classified
+
effects of enforcement activity

Questa distinzione è fondamentale.

Una maggiore concentrazione di osservazioni in una zona può derivare da un maggior numero di eventi, ma può anche essere influenzata da maggiore presenza operativa, differenti livelli di denuncia, procedure amministrative o qualità diversa del dato.

Se utilizziamo questi dati senza comprenderne il processo generativo, rischiamo di creare un feedback loop:

more observations
      ↓
higher estimated risk
      ↓
more attention
      ↓
more detected events
      ↓
even higher estimated risk

La precisione matematica del modello non elimina il bias del processo che ha prodotto i dati.

18. Dal GIS descrittivo alla Spatial Intelligence

Crime Analysis 6.0 mostra bene quanto il GIS moderno si stia allontanando dalla semplice rappresentazione cartografica.

Il percorso ormai è:

Where did it happen?
        ↓
Where is it concentrated?
        ↓
Is the concentration statistically significant?
        ↓
How is the pattern evolving?
        ↓
How does risk decay in space and time?
        ↓
Are apparently separate events related?
        ↓
Did an intervention change the pattern?
        ↓
What evidence supports the conclusion?

E il prossimo passo potrebbe essere:

Can an intelligent spatial agent
autonomously assemble the right analyses,
test competing hypotheses,
retrieve supporting evidence
and explain its conclusions?

Conclusione

Crime Analysis 6.0 è interessante non tanto perché aggiunge nuovi pulsanti ad ArcGIS Pro, ma perché rende evidente una trasformazione più ampia.

La geografia del crimine sta diventando sempre più una disciplina spazio-temporale, probabilistica e relazionale.

Aoristic Analysis ci ricorda che anche il tempo può essere incerto. Repeat/Near-Repeat Analysis introduce il decadimento spazio-temporale del rischio. Space-Time Cube permette di osservare l'evoluzione dei cluster. Weighted Displacement Difference porta l'analisi verso la valutazione degli interventi.

Embeddings, vector search, graph analytics e GeoAI possono costituire il livello successivo, purché non sostituiscano la statistica con un modello linguistico.

Il GIS del futuro non dovrà semplicemente mostrare dove si trova qualcosa.

Dovrà essere in grado di spiegare:

what happened
where
when
how it is connected
how certain we are
how the pattern is changing
and which evidence supports the hypothesis.

È in quel momento che una mappa smette di essere soltanto una rappresentazione dello spazio e diventa un vero sistema di Spatial Intelligence.