# Imparare le LRU - Quando l'eccesso di capacità rende il vostro sistema migliore

<!--category-- ASP.NET, Architecture, CQRS, Bot Detection, Caching, Systems Design -->
<datetime class="hidden">2025-12-09T12:00</datetime>

La maggior parte dei sistemi degrada quando sovraccaricato. La memoria si riempie, le query rallentano, gli utenti si lamentano, i server si bloccano.

Alcuni insoliti ottenere *meglio*.

Questo articolo mostra come un **Memoria comportamentale basata su LRU** diventa auto-ottimizzante quando colpisce la capacità - e come questo modello alimenta il sistema di apprendimento nel mio [Motore di rilevamento bot](/blog/botdetection-introduction). Questa è anche la versione più piccola possibile di [Architettura DiSE](/blog/dise-architecture-overview) - l'evoluzione controllata attraverso la pressione delle risorse.

Se hai letto il mio articolo su [CQRS ed Event Sourcing](/blog/moderncqrsandeventsourcing), riconoscerete alcuni dei modelli qui. Ma questo è CQRS spogliato fino all'osso - nessun negozio di eventi, nessuna proiezione, nessun Marten. Solo una cache di memoria, un lavoratore di background, e SQLite.

[TOC]

## L'idea di base - Memoria comportamentale su un bilancio

Prima di tuffarci, fammi definire un termine che userò in tutto: **firma**. Una firma è qualsiasi chiave stabile che rappresenta un modello di comportamento - un hash di IP + User-Agent, un'impronta digitale di combinazioni di intestazione, una classificazione del rivelatore di "questa richiesta assomiglia a X." La cache memorizza queste firme insieme ai pesi appresi che si evolvono nel tempo.

E se potessi costruire un sistema che:

- Risponde in meno di 1ms
- Mai blocchi sul database scrive
- Autoottimizza sotto pressione
- Dimentica quello che non importa.
- Ricorda cosa fa

E' esattamente quello che `IMemoryCache` con scadenza scorrevole ti dà - se capisci cosa stai costruendo.

### Che cosa fa una cache LRU in realtà

LRU (Least Recently Used) cache sfratto voci che non sono state accessibili di recente. Quando la cache si riempie, le voci più fredde vengono gettati fuori per fare spazio per quelli caldi.

La maggior parte degli sviluppatori vede questo come una limitazione. *"Oh no, la mia cache è piena, i dati vengono persi!"*

Ma per i sistemi comportamentali, questa è una caratteristica.

```csharp
// From WeightStore.cs - the bounded memory window
_cache = new MemoryCache(new MemoryCacheOptions
{
    SizeLimit = _cacheSize,           // e.g., 1000 entries
    CompactionPercentage = 0.25       // Remove 25% when limit reached
});
```

Che `SizeLimit` non è solo un vincolo di memoria. **pressione di selezione**Determina quanto il sistema "ricorda" e lo costringe a concentrarsi su ciò che conta.

### Scadenza scorrevole - L'orologio che dimentica

Combinato con scadenza scorrevole, si ottiene automaticamente dimenticando:

```csharp
// From WeightStore.cs:254-259
private MemoryCacheEntryOptions GetCacheEntryOptions()
{
    return new MemoryCacheEntryOptions()
        .SetSlidingExpiration(_slidingExpiration)  // 30 minutes
        .SetSize(1);  // Each entry counts as 1 toward size limit
}
```

Se una firma non è accessibile entro 30 minuti, è sfrattato. Non perché è sbagliato - perché non è più rilevante.

Questo crea **dimenticanza naturale**:

- Gli IP vengono riassegnati
- I bot ruotano le firme
- Spostamento dei modelli di traffico
- Gli attacchi di ieri non sono quelli di oggi.

I blocklist statici sono stantii. La scadenza scorrevole mantiene la memoria fresca.

## Il modello IMemoryCache - CQRS piccolo senza dire CQRS

Ecco lo schema che lo fa funzionare. `SqliteWeightStore` classe:

```csharp
/// <summary>
///     SQLite implementation of the weight store with sliding expiration memory cache.
///     Uses a CQRS-style pattern with write-behind:
///     - Reads: Hit memory cache first (fast path), fall back to SQLite on miss
///     - Writes: Update cache immediately, queue SQLite writes for background flush
///     Sliding expiration provides automatic LRU-like eviction behavior.
/// </summary>

public class SqliteWeightStore : IWeightStore, IAsyncDisposable
{
    // Memory cache with sliding expiration - auto-evicts least recently used entries
    private readonly MemoryCache _cache;

    // Write-behind queue for batched SQLite persistence
    private readonly ConcurrentDictionary<string, PendingWrite> _pendingWrites = new();
    private readonly Timer _flushTimer;
    private readonly TimeSpan _flushInterval = TimeSpan.FromMilliseconds(500);
```

Questo e' [CQRS informale](/blog/moderncqrsandeventsourcing#part-2-the-half-assed-approach-cache-invalidation) - ma senza la noiosa invalidazione della cache. Invece di invalidare le voci della cache dopo la scrittura, **la cache è il modello di scrittura**. SQLite è solo il libro mastro durevole.

```mermaid
flowchart LR
    subgraph Cache["In-Memory Behaviour Store"]
        A[Hot Signatures] --- B[Sliding Expiry]
    end
    subgraph DB["SQLite Ledger"]
        C[(Durable Write-Behind)]
    end

    A --Periodic Flush--> C
    B --Eviction--> D[Forgotten]

    style Cache fill:none,stroke:#10b981,stroke-width:2px
    style DB fill:none,stroke:#6366f1,stroke-width:2px
```

Intuizione chiave: **legge e scrive andare alla memoria**. Il database è alla fine coerente - e va bene.

## Il percorso di lettura - Cache prima, sempre

Quando un rivelatore ha bisogno di un peso imparato, colpisce la cache:

```csharp
// From WeightStore.cs:421-472
public async Task<double> GetWeightAsync(
    string signatureType,
    string signature,
    CancellationToken ct = default)
{
    var key = CacheKey(signatureType, signature);

    // Check cache first (fast path - no DB access)
    if (_cache.TryGetValue(key, out LearnedWeight? cached) && cached != null)
    {
        _metrics?.RecordCacheHit(signatureType);
        return cached.Weight * cached.Confidence;
    }

    _metrics?.RecordCacheMiss(signatureType);

    // Cache miss - load from DB
    await EnsureInitializedAsync(ct);

    await using var conn = new SqliteConnection(_connectionString);
    await conn.OpenAsync(ct);

    var sql = $@"
        SELECT weight, confidence, observation_count, first_seen, last_seen
        FROM {TableName}
        WHERE signature_type = @type AND signature = @sig
    ";

    // ... execute query ...

    if (await reader.ReadAsync(ct))
    {
        // Cache the result for future reads
        var learnedWeight = new LearnedWeight { /* ... */ };
        _cache.Set(key, learnedWeight, GetCacheEntryOptions());

        return weight * confidence;
    }

    return 0.0;  // No learned weight exists
}
```

Percorsi caldi **non ha mai colpito il database**. La cache è la fonte di verità per leggere. SQLite è solo backup storage.

## Il percorso di scrittura - Cache Immediatamente, Persistere Più tardi

Quando il sistema impara qualcosa di nuovo, aggiorna immediatamente la cache e mette in coda il database scrive:

```csharp
// From WeightStore.cs:551-582
public Task UpdateWeightAsync(
    string signatureType,
    string signature,
    double weight,
    double confidence,
    int observationCount,
    CancellationToken ct = default)
{
    var key = CacheKey(signatureType, signature);

    // Update cache immediately (source of truth for reads)
    var learnedWeight = new LearnedWeight
    {
        SignatureType = signatureType,
        Signature = signature,
        Weight = weight,
        Confidence = confidence,
        ObservationCount = observationCount,
        FirstSeen = DateTimeOffset.UtcNow,
        LastSeen = DateTimeOffset.UtcNow
    };
    _cache.Set(key, learnedWeight, GetCacheEntryOptions());

    // Queue for async SQLite persistence (write-behind)
    QueueWrite(signatureType, signature, weight, confidence, observationCount);

    return Task.CompletedTask;
}
```

Avviso: `UpdateWeightAsync` ritorna `Task.CompletedTask` - è essenzialmente sincrono. La scrittura è in coda, non eseguita. Questo significa:

- Latenza di scrittura sub-millisecondo
- Nessun blocco sull'I/O
- Gli scritti sono coalesced (vince l'ultima scrittura)

## Il Flusher di sfondo - 500m di magia noiosa

Ogni 500m, in attesa di scrittura vengono lavati su SQLite in un unico lotto:

```csharp
// From WeightStore.cs:274-357
public async Task FlushPendingWritesAsync(CancellationToken ct = default)
{
    if (_pendingWrites.IsEmpty) return;

    // Only one flush at a time
    if (!await _flushLock.WaitAsync(0, ct)) return;

    try
    {
        await EnsureInitializedAsync(ct);

        // Snapshot and clear pending writes atomically
        var writes = new List<PendingWrite>();
        foreach (var key in _pendingWrites.Keys.ToList())
        {
            if (_pendingWrites.TryRemove(key, out var write))
            {
                writes.Add(write);
            }
        }

        if (writes.Count == 0) return;

        await using var conn = new SqliteConnection(_connectionString);
        await conn.OpenAsync(ct);
        await using var transaction = await conn.BeginTransactionAsync(ct);

        try
        {
            var sql = $@"
                INSERT INTO {TableName}
                    (signature_type, signature, weight, confidence,
                     observation_count, first_seen, last_seen)
                VALUES (@type, @sig, @weight, @conf, @count, @now, @now)
                ON CONFLICT(signature_type, signature) DO UPDATE SET
                    weight = @weight,
                    confidence = @conf,
                    observation_count = @count,
                    last_seen = @now
            ";

            foreach (var write in writes)
            {
                await using var cmd = new SqliteCommand(sql, conn, transaction);
                // ... add parameters and execute ...
            }

            await transaction.CommitAsync(ct);
            _logger.LogDebug("Flushed {Count} pending writes in {Duration:F1}ms",
                writes.Count, sw.ElapsedMilliseconds);
        }
        catch
        {
            await transaction.RollbackAsync(ct);
            throw;
        }
    }
    finally
    {
        _flushLock.Release();
    }
}
```

Questo e' **Event-sourcing-light**. Ottieni:

- Scritture in lotti (I/O efficiente)
- Coerenza delle operazioni
- Aggiornamenti coalesced (se la stessa firma viene aggiornata 10 volte in 500ms, solo il valore finale è scritto)
- SQLite è perfettamente soddisfatto di questo modello di accesso

## Aggiornamenti EMA - Imparare con le medie mobili esponenziali

Quando arriva una nuova osservazione, il sistema utilizza medie mobili esponenziali per aggiornare i pesi:

```csharp
// From WeightStore.cs:584-635
public Task RecordObservationAsync(
    string signatureType,
    string signature,
    bool wasBot,
    double detectionConfidence,
    CancellationToken ct = default)
{
    var key = CacheKey(signatureType, signature);

    // Calculate new weight using EMA in memory
    var alpha = 0.1;  // Learning rate
    var weightDelta = wasBot ? detectionConfidence : -detectionConfidence;

    double newWeight;
    double newConfidence;
    int newObservationCount;

    if (_cache.TryGetValue(key, out LearnedWeight? existing) && existing != null)
    {
        // Apply EMA: new_weight = old_weight * (1-α) + delta * α
        newWeight = existing.Weight * (1 - alpha) + weightDelta * alpha;
        newConfidence = Math.Min(1.0, existing.Confidence + detectionConfidence * 0.01);
        newObservationCount = existing.ObservationCount + 1;
    }
    else
    {
        // First observation
        newWeight = weightDelta;
        newConfidence = detectionConfidence;
        newObservationCount = 1;
    }

    // Update cache immediately
    var learnedWeight = new LearnedWeight
    {
        SignatureType = signatureType,
        Signature = signature,
        Weight = newWeight,
        Confidence = newConfidence,
        ObservationCount = newObservationCount,
        FirstSeen = existing?.FirstSeen ?? DateTimeOffset.UtcNow,
        LastSeen = DateTimeOffset.UtcNow
    };
    _cache.Set(key, learnedWeight, GetCacheEntryOptions());

    // Queue for persistence
    QueueWrite(signatureType, signature, newWeight, newConfidence, newObservationCount);

    return Task.CompletedTask;
}
```

La formula EMA facilita l'apprendimento: `new_weight = old_weight × (1 - α) + new_value × α`

Con α = 0,1:

- Nuove prove contribuiscono al 10%
- Le prove storiche contribuiscono al 90%
- Questo impedisce oscillazioni selvagge da singole osservazioni

## Perché Overflow rende il sistema *Meglio*

Ecco l'intuizione chiave che la maggior parte della gente manca.

Quando la cache si riempie:

- Le firme a bassa frequenza cadono
- Solo le firme calde (spesso accessibili) rimangono in memoria
- Il database è in ritardo di un ciclo di flush - e va bene
- Il sistema **focus** sotto pressione

Pensaci: se 50.000 firme uniche colpiscono il tuo bot detector, ma hai solo memoria per 10.000, quali firme contano?

**I più caldi 10.000** - che rappresentano in genere il 99% del traffico effettivo.

Nella produzione, vedo circa 40.000 firme una tantum al giorno (i grattacieli che provano una volta, le sonde casuali, gli utenti legittimi che non ritornano) e forse 5 040.10.000 che si ripetono costantemente. Quei 5 040.10.000 sono dove vive il 99% del rischio. Le firme a coda lunga? Noise. Evicting loro non danneggia l'accuratezza di rilevamento - potrebbe anche migliorarlo riducendo i falsi positivi da modelli di bassa fiducia.

```mermaid
flowchart TB
    subgraph Input["50,000 Unique Signatures"]
        Hot[Hot Signatures\n~10,000]
        Cold[Cold Signatures\n~40,000]
    end

    subgraph Cache["Bounded Cache (10,000)"]
        Kept[Kept in Memory]
    end

    subgraph Evicted["Evicted"]
        Lost[Forgotten\nNoise Traffic]
    end

    Hot --> Kept
    Cold --> Lost

    style Hot fill:none,stroke:#10b981,stroke-width:2px
    style Cold fill:none,stroke:#94a3b8,stroke-width:2px
    style Kept fill:none,stroke:#10b981,stroke-width:2px
    style Lost fill:none,stroke:#ef4444,stroke-width:2px
```

Sovraccarico **affilati** la memoria comportamentale. Il sistema si auto-ottimizza.

### Quando l'overflow non aiuta

Non e' magia, ci sono casi in cui la pressione dell'LRU funziona contro di te:

- **Cache troppo piccola**: Se la tua cache contiene solo 100 voci ma hai 1.000 firme veramente importanti, lancerai costantemente e perderai modelli utili prima che accumulino abbastanza prove. Dimendi la tua cache per tenere comodamente il tuo "hot set."

- **Traffico uniforme**: Se si sta eseguendo su un piccolo sistema interno dove quasi tutto è "caldo" (poche firme uniche, tutto ricorrente), overflow ti dà meno beneficio. La pressione di selezione non ha nulla da selezionare contro.

- **Problemi di avviamento a freddo**: Un sistema appena implementato non ha pesi appresi. Tutto è ugualmente freddo. Le prime ore avranno tassi falsi positivi più alti fino a quando le firme calde si stabiliscono.

Il modello funziona meglio quando si dispone **alta cardinalità con distribuzione power-law** - un sacco di firme uniche, ma un piccolo sottoinsieme che domina il traffico.

## Mantenere la cache e il database in sincronizzazione - Tag-Based Invalidation

La cache è la fonte della verità per leggere, ma il database è il registro durevole. Cosa succede quando si spostano?

### Sincronizzazione del decadimento

Quando il database pesa decadimento, la cache deve seguire. `DecayOldWeightsAsync` il metodo gestisce entrambi:

```csharp
// From WeightStore.cs:725-766
public async Task DecayOldWeightsAsync(TimeSpan maxAge, double decayFactor, CancellationToken ct = default)
{
    await EnsureInitializedAsync(ct);

    await using var conn = new SqliteConnection(_connectionString);
    await conn.OpenAsync(ct);

    var cutoff = DateTimeOffset.UtcNow.Subtract(maxAge).ToString("O");

    // Decay old weights in the database
    var sql = $@"
        UPDATE {TableName}
        SET weight = weight * @decay,
            confidence = confidence * @decay
        WHERE last_seen < @cutoff
    ";

    await using var cmd = new SqliteCommand(sql, conn);
    cmd.Parameters.AddWithValue("@decay", decayFactor);
    cmd.Parameters.AddWithValue("@cutoff", cutoff);

    var updated = await cmd.ExecuteNonQueryAsync(ct);

    // Delete weights that have decayed below threshold
    var deleteSql = $@"
        DELETE FROM {TableName}
        WHERE confidence < 0.01 OR (ABS(weight) < 0.01 AND observation_count < 5)
    ";

    await using var deleteCmd = new SqliteCommand(deleteSql, conn);
    var deleted = await deleteCmd.ExecuteNonQueryAsync(ct);

    if (updated > 0 || deleted > 0)
    {
        _logger.LogInformation(
            "Weight decay: {Updated} decayed, {Deleted} deleted",
            updated, deleted);

        // Compact cache to remove stale entries
        _cache.Compact(0.25);
    }
}
```

Dopo il decadimento dei registri del database, chiamiamo `_cache.Compact(0.25)` - questo forza il `MemoryCache` per sfrattare il 25% delle sue voci, dando la priorità al meno recentemente utilizzato. La prossima lettura ricarica i valori freschi dal database.

### Evizione basata sul tag

A volte è necessario invalidare un'intera categoria di voci della cache - per esempio, quando si riqualifica un rivelatore o quando i dati esterni cambiano:

```csharp
// From WeightStore.cs:768-777
/// <summary>
///     Evicts all cached entries for a specific signature type (tag-based eviction).
/// </summary>

public void EvictByTag(string signatureType)
{
    // MemoryCache doesn't natively support tag-based eviction, but we can compact
    // For now, just compact - sliding expiration will handle stale entries
    _cache.Compact(0.1);
    _logger.LogDebug("Compacted cache for signature type: {SignatureType}", signatureType);
}
```

.NET's `MemoryCache` non ha lo sfratto nativo basato su tag come Redis, ma la compattazione raggiunge lo stesso effetto: forzare le voci stantie, lasciare leggere repopulate dal database.

### La strategia di sincronizzazione

L'intuizione chiave è che **La perfetta sincronizzazione non è necessaria**. Il sistema tollera la deriva perché:

1. **Cache manca il caricamento da DB** - se una voce viene sfrattata, la prossima lettura raccoglie dati nuovi
2. **Scorrevole scadenza maniglie stantia** - voci non accessibili entro 30 minuti auto-evict
3. **Rinnovamento delle forze di compattazione** - compattazione periodica spinge fuori vecchie voci
4. **Aggiornamenti di coalesce scritte-dietro** - multipli aggiornamenti rapidi diventano una scrittura DB

Questa è l'eventuale coerenza fatta bene. La cache rimane "abbastanza vicina" al database senza richiedere complesse logiche di invalidazione.

```mermaid
flowchart TB
    subgraph Sync["Cache-Database Synchronisation"]
        D[Database Decay] --> C[Cache Compact]
        E[Tag Eviction] --> C
        S[Sliding Expiration] --> M[Cache Miss]
        M --> R[Reload from DB]
    end

    style Sync fill:none,stroke:#6366f1,stroke-width:2px
```

Nessun inferno di nullità della cache. Nessun pub/sub complesso. Solo compattazione e naturale scadenza.

## Andare più a fondo: il sistema di reputazione

> **Nota:** Se volevi solo il modello LRU + write-behind, puoi fermarti qui. Il resto di questo articolo mostra come applico le stesse idee alla piena reputazione del modello - macchine di stato, isteresi e decadimento del tempo. E' il "miglio supplementare" per quei sistemi adattivi di costruzione.

### Isteresi e Decadimento

Per la reputazione del modello (tracciando se una firma è bot o umano nel tempo), si applicano gli stessi principi ma con ulteriore sofisticazione:

```csharp
// From PatternReputation.cs:42-108
public record PatternReputation
{
    public required string PatternId { get; init; }
    public required string PatternType { get; init; }
    public required string Pattern { get; init; }

    /// <summary>Current bot probability [0,1]. 0 = human, 1 = bot, 0.5 = neutral</summary>

    public double BotScore { get; init; } = 0.5;

    /// <summary>Effective sample count - decays over time, increases with observations</summary>

    public double Support { get; init; } = 0;

    /// <summary>Current reputation state - determines fast-path behavior</summary>

    public ReputationState State { get; init; } = ReputationState.Neutral;

    // Computed properties
    public double Confidence => Math.Min(1.0, Support / 100.0);

    public bool CanTriggerFastAbort =>
        State is ReputationState.ConfirmedBad or ReputationState.ManuallyBlocked;

    public bool CanTriggerFastAllow =>
        State is ReputationState.ConfirmedGood or ReputationState.ManuallyAllowed;
}
```

### Transizioni statali con l'isteresi

I modelli non girano direttamente da Neutral a ConfermatoBad. C'è isteresi per evitare sbalzi:

```csharp
// From PatternReputation.cs:367-421 - simplified
public PatternReputation EvaluateStateChange(PatternReputation reputation)
{
    if (reputation.IsManual)
        return reputation;

    var newState = reputation.State;
    var score = reputation.BotScore;
    var support = reputation.Support;

    switch (reputation.State)
    {
        case ReputationState.Neutral:
            // Can promote to Suspect or ConfirmedGood
            if (score >= 0.6 && support >= 10)
                newState = ReputationState.Suspect;
            else if (score <= 0.1 && support >= 100)
                newState = ReputationState.ConfirmedGood;
            break;

        case ReputationState.Suspect:
            // Can promote to ConfirmedBad or demote to Neutral
            if (score >= 0.9 && support >= 50)
                newState = ReputationState.ConfirmedBad;
            else if (score <= 0.4 || support < 10)
                newState = ReputationState.Neutral;
            break;

        case ReputationState.ConfirmedBad:
            // Can demote to Suspect (requires MORE evidence to forgive)
            if (score <= 0.7 && support >= 100)
                newState = ReputationState.Suspect;
            break;
    }

    // ... log state change and return ...
}
```

Notate l'asimmetria: è più facile bloccarsi che sbloccare. `ConfirmedBad → Suspect` richiede 100 supporto, mentre `Neutral → Suspect` Ne servono solo 10. Questo è intenzionale - è più difficile perdonare che sospettare.

### Decadimento del tempo - Dimenticare esponenziale

Quando i modelli si calmano, si declinano verso la neutralità:

```csharp
// From PatternReputation.cs:334-361
public PatternReputation ApplyTimeDecay(PatternReputation reputation)
{
    if (reputation.IsManual)
        return reputation;

    var hoursSinceLastSeen = (DateTimeOffset.UtcNow - reputation.LastSeen).TotalHours;

    if (hoursSinceLastSeen < 1)
        return reputation;  // Too recent to decay

    // Score decay toward prior (0.5 = neutral)
    // new_score = old_score + (prior - old_score) × (1 - e^(-Δt/τ))
    var scoreDecayFactor = 1 - Math.Exp(-hoursSinceLastSeen / _options.ScoreDecayTauHours);
    var newScore = reputation.BotScore + (0.5 - reputation.BotScore) * scoreDecayFactor;

    // Support decay
    // new_support = old_support × e^(-Δt/τ)
    var supportDecayFactor = Math.Exp(-hoursSinceLastSeen / _options.SupportDecayTauHours);
    var newSupport = reputation.Support * supportDecayFactor;

    return reputation with
    {
        BotScore = Math.Clamp(newScore, 0, 1),
        Support = newSupport
    };
}
```

Le costanti di tempo predefinite:

- **Punteggio di decadimento τ**: 168 ore (7 giorni) - i punteggi si muovono del 63% verso neutro dopo una settimana di inattività
- **Decadimento del supporto τ**: 336 ore (14 giorni) - calo di confidenza 63% dopo due settimane

Ciò significa che un IP confermato-cattivo che va tranquillo per un mese alla fine ridiscenderà a Neutral. Non perché si è riformato - perché la sua evidenza è diventata stantia.

## Il servizio di manutenzione di base

La `ReputationMaintenanceService` svolge tre compiti periodici:

```csharp
// From ReputationMaintenanceService.cs:48-129
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    _logger.LogInformation("Reputation maintenance service starting");

    // Load persisted reputations on startup
    await _cache.LoadAsync(stoppingToken);

    var decayInterval = TimeSpan.FromMinutes(60);    // Hourly decay sweep
    var gcInterval = TimeSpan.FromHours(24);          // Daily garbage collection
    var persistInterval = TimeSpan.FromMinutes(5);   // Persist every 5 minutes

    var lastDecay = DateTimeOffset.UtcNow;
    var lastGc = DateTimeOffset.UtcNow;
    var lastPersist = DateTimeOffset.UtcNow;

    while (!stoppingToken.IsCancellationRequested)
    {
        await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
        var now = DateTimeOffset.UtcNow;

        // Decay sweep: push stale scores toward neutral
        if (now - lastDecay >= decayInterval)
        {
            await _cache.DecaySweepAsync(stoppingToken);
            lastDecay = now;
        }

        // Garbage collection: remove old neutral patterns
        if (now - lastGc >= gcInterval)
        {
            await _cache.GarbageCollectAsync(stoppingToken);
            lastGc = now;

            var stats = _cache.GetStats();
            _logger.LogInformation(
                "Reputation stats: {Total} patterns, {Bad} bad, {Suspect} suspect",
                stats.TotalPatterns, stats.ConfirmedBadCount, stats.SuspectCount);
        }

        // Persistence: save to SQLite
        if (now - lastPersist >= persistInterval)
        {
            await _cache.PersistAsync(stoppingToken);
            lastPersist = now;
        }
    }

    // Final persist on shutdown
    await _cache.PersistAsync(CancellationToken.None);
}
```

Il raccoglitore di rifiuti rimuove i modelli che sono:

- 90+ giorni di età
- Supporto ≤ 1,0
- In stato neutro

Questo impedisce al dizionario in-memory di crescere senza limiti, pur conservando preziosi modelli appresi.

## SQLite non è uno scherzo - è perfetto qui

Un sacco di sviluppatori reflexively raggiungere per PostgreSQL o Redis. Ma per questo modello, SQLite è ideale:

1. **Write behind elimina le strozzature** - La limitazione del singolo scrittore di SQLite non importa quando le scritture sono in batch
2. **Deposito locale** - nessuna latenza di rete, nessuna piscina di collegamento
3. **Configurazione zero** - solo un percorso di file
4. **Perfetto per la distribuzione dei bordi** - corre su un Raspberry Pi
5. **Portabile** - il database è solo un file che puoi copiare in giro

Lo schema è minimo:

```sql
CREATE TABLE IF NOT EXISTS learned_weights (
    signature_type TEXT NOT NULL,
    signature TEXT NOT NULL,
    weight REAL NOT NULL,
    confidence REAL NOT NULL,
    observation_count INTEGER NOT NULL DEFAULT 1,
    first_seen TEXT NOT NULL,
    last_seen TEXT NOT NULL,
    PRIMARY KEY (signature_type, signature)
);

CREATE INDEX IF NOT EXISTS idx_signature_type ON learned_weights(signature_type);
CREATE INDEX IF NOT EXISTS idx_confidence ON learned_weights(confidence);
CREATE INDEX IF NOT EXISTS idx_last_seen ON learned_weights(last_seen);
```

Se hai bisogno di una scala più grande, scambia su PostgreSQL. Se hai bisogno di HA o replica, scambia su Redis o una cache distribuita. L'architettura non cambia - solo la stringa di connessione. SQLite è l'impostazione predefinita per l'implementazione dei bordi, non una religione.

## Il DiSE Tie-in - Fallimento come evoluzione

Se [DiSE](/blog/dise-architecture-overview) è il motore evolutivo completo, questo modello di cache è il mitocondrio - il pezzo più piccolo che si comporta ancora come evoluzione sotto costrizione.

Questo modello implementa i principi DiSE al livello più minimo:

- **Costrizione delle risorse** → Pressione di selezione
- **LRU sfratto** → Selezione naturale (i sopravvissuti sono i più adatti)
- **Decadimento temporale** → Dimenticare consente l'adattamento
- **Aggiornamenti EMA** → Mutazione attraverso l'osservazione
- **Isteresi** → Stabilità attraverso la resistenza al cambiamento

Una cache completa non e' un fallimento. **pressione evolutiva**. Il sistema si auto-ottimizza: le firme calde rimangono, le firme fredde sfrattano, e la memoria comportamentale converge verso ciò che realmente conta.

Niente training ML, niente modelli esterni, solo architettura che si comporta come un sistema vivente.

## Conclusione - Semplici strutture, comportamento emergente

L'intero schema si riduce a:

1. **Cache è la fonte della verità per leggere** - accesso sub-millisecondo
2. **Scrive la cache di aggiornamento immediatamente, persistere più tardi** - nessun blocco di I/O
3. **Scadenza scorrevole fornisce LRU automatico** - integrato in .NET
4. **Dimensione delimitata crea pressione di selezione** - l'overflow affila la messa a fuoco
5. **Il colore di sfondo mantiene SQLite in sincronizzazione** - l'eventuale consistenza va bene
6. **Il decadimento del tempo permette di dimenticare** - spariscono le prove stantie.

Il tuo piccolo negozio comportamentale agisce più come un sistema vivente che come CRUD. Ricorda ciò che conta, dimentica ciò che non fa, e migliora sotto pressione.

Architettura minimale → correttezza emergente.
Overflow → messa a fuoco migliore.
Pressione → stabilità.

Se vuoi vedere questo in azione, check out [per lo piùlucid.botdetection](/blog/botdetection-introduction) - e la [Serie di architettura DiSE](/blog/dise-architecture-overview) per la filosofia più profonda.

## Collegamenti

- [Introduzione alla rilevazione di bot](/blog/botdetection-introduction) - il sistema che utilizza questi modelli
- [Panoramica dell'architettura di DiSE](/blog/dise-architecture-overview) - evoluzione controllata attraverso la pressione
- [Moderno CQRS ed Event Sourcing](/blog/moderncqrsandeventsourcing) - il modello completo (quando ne hai bisogno)
- [GitHub: principalmentelucid.botdetection](https://github.com/scottgal/mostlylucid.nugetpackages) - codice sorgente completo
- [Pacchetto NuGet](https://www.nuget.org/packages/mostlylucid.botdetection/) - installarlo