Files
Mimante/Mimante/Utilities/StatsMaintenance.cs
T
Alby96andClaude Opus 5 1d578debe2 Togli dal percorso caldo la rilettura dell'intero storico
Sei modifiche chieste in questa sessione, di cui una pesa piu' di tutte le altre.

CompletedAuctionsStore rileggeva e rideserializzava l'intero file a ogni chiamata:
misurati 72 ms su 5876 record, 3,9 MB. Append pagava quel prezzo piu' la
riserializzazione e la riscrittura completa per cambiare un record solo, 120 ms
in tutto, e la chiamata arriva da un gestore di eventi di MainWindow, quindi dal
thread dell'interfaccia, che restava fermo a ogni asta conclusa. Il recupero
delle puntate dei vincitori lo faceva in un ciclo: 4675 aste erano 7,2 minuti di
solo rimescolamento JSON e 17,8 GB scritti sul disco.

Adesso c'e' una copia in memoria invalidata sulla data di modifica del file, cosi'
resta giusta anche se il file viene sostituito da fuori (ripristino di un backup),
e un AppendMany che applica molti record con un salvataggio solo, con un indice
per id perche' il caso per cui esiste non torni quadratico. Il recupero salva a
blocchi di cinquanta e deposita comunque quello che ha in mano quando esce, anche
per annullamento: le richieste gia' fatte sono state pagate al server.

Misurato sullo stesso file: 95 volte piu' veloce, 7,2 minuti diventano 4,5
secondi, 17,8 GB diventano 0,36 GB.

Le altre cinque:

- Il setup si chiama AutoBidder_<versione>.exe.
- I pulsanti di stop sono rossi, sia quello globale sia quelli per asta nella
  griglia: erano gli unici tre senza colore mentre avvia e osserva ce l'avevano.
- La pulizia dello storico sa togliere le aste seguite solo in parte. Non serve
  nessun campo nuovo: il prezzo finale in centesimi e' il totale delle puntate
  dell'asta, quindi il rapporto con i reset osservati dice quanta asta si e'
  vista, e la regola vale anche sullo storico gia' raccolto. Verificata sui dati
  veri: su 5876 aste nessuna supera copertura 1,02, che e' quanto ci si aspetta
  se l'identita' e' giusta.
- La griglia ricicla i contenitori di riga invece di ricrearli.
- Il costo puntata gia' esisteva col predefinito giusto: al suggerimento e' stato
  aggiunto perche' 0,20 e' il numero da tenere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 14:48:54 +02:00

213 lines
9.6 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using AutoBidder.Models;
namespace AutoBidder.Utilities
{
/// <summary>
/// Manutenzione dello storico: ripara ciò che si può ricalcolare, e toglie ciò che non
/// si può più rendere attendibile.
///
/// <para><b>Perché le due cose stanno insieme.</b> Un archivio cresciuto per mesi
/// contiene record scritti da versioni diverse dell'applicazione: alcuni hanno campi che
/// allora non esistevano, altri hanno numeri che non tornano. I primi si sistemano da
/// soli — la chiave prodotto si ricava dal nome, l'esito dal vincitore — e vanno riparati
/// in silenzio all'avvio. I secondi no: un prezzo a zero o un conteggio incoerente non si
/// possono indovinare, e l'unica scelta onesta è dire quali sono e lasciare all'utente la
/// decisione se buttarli.</para>
///
/// <para>Classe pura: lavora su liste in memoria e non tocca il disco. Chi la usa decide
/// se e quando salvare — così la stessa logica serve l'avvio, il pulsante di pulizia e
/// i test, senza tre versioni che divergono.</para>
/// </summary>
public static class StatsMaintenance
{
// ── Riparazione automatica ───────────────────────────────────────
/// <summary>Cosa è stato sistemato, per poterlo scrivere nel registro.</summary>
public sealed record RepairReport(int ProductKeys, int Outcomes, int Names)
{
public int Total => ProductKeys + Outcomes + Names;
public bool AnythingDone => Total > 0;
}
/// <summary>
/// Ricalcola i campi mancanti che si possono dedurre con certezza dagli altri.
/// Non inventa nulla: se il dato non è deducibile, resta com'è.
/// </summary>
public static RepairReport Repair(IList<CompletedAuctionRecord> records)
{
if (records == null) return new RepairReport(0, 0, 0);
int keys = 0, outcomes = 0, names = 0;
foreach (var r in records)
{
// La chiave prodotto è ciò che raggruppa le statistiche: senza, l'asta
// finisce in un gruppo tutto suo e sparisce dalle medie del prodotto.
if (string.IsNullOrWhiteSpace(r.ProductKey) && !string.IsNullOrWhiteSpace(r.Name))
{
r.ProductKey = ProductKeyHelper.GenerateProductKey(r.Name);
keys++;
}
// Un nome vuoto rende la riga illeggibile in griglia; l'id è brutto ma vero.
if (string.IsNullOrWhiteSpace(r.Name) && !string.IsNullOrWhiteSpace(r.AuctionId))
{
r.Name = r.AuctionId;
names++;
}
// L'esito si deduce: se ho vinto è "Vinta", se c'è un vincitore diverso
// è "Persa", se non c'è nessun vincitore l'asta è scaduta.
if (string.IsNullOrWhiteSpace(r.Outcome))
{
r.Outcome = r.WonByMe ? "Vinta"
: !string.IsNullOrWhiteSpace(r.Winner) ? "Persa"
: "Chiusa";
outcomes++;
}
}
return new RepairReport(keys, outcomes, names);
}
// ── Pulizia su richiesta ─────────────────────────────────────────
/// <summary>
/// Cosa considerare "incompleto". Ogni voce è una scelta dell'utente, perché il
/// giusto compromesso dipende da cosa ci si vuole fare: chi tara le strategie vuole
/// solo record perfetti, chi guarda quanto spende si tiene anche quelli parziali.
/// </summary>
public sealed class CleanupOptions
{
/// <summary>Aste senza prezzo finale: non dicono nulla e sporcano ogni media.</summary>
public bool RemoveWithoutPrice { get; set; } = true;
/// <summary>Aste senza vincitore: scadute senza offerte, oppure registrate a metà.</summary>
public bool RemoveWithoutWinner { get; set; }
/// <summary>Aste di cui non si conoscono le puntate del vincitore.</summary>
public bool RemoveWithoutWinnerBids { get; set; }
/// <summary>Aste il cui conteggio puntate non regge il confronto col prezzo.</summary>
public bool RemoveInconsistentBids { get; set; } = true;
/// <summary>Aste senza valore "Compra Subito": senza, non si calcola il risparmio.</summary>
public bool RemoveWithoutValue { get; set; }
/// <summary>Doppioni con lo stesso id: si tiene il più recente.</summary>
public bool RemoveDuplicates { get; set; } = true;
/// <summary>
/// Copertura minima richiesta, da 0 a 1: sotto questa soglia l'asta è stata
/// agganciata a metà e va tolta. Null = non filtrare sulla copertura.
///
/// <para>È il criterio per «non seguita dall'inizio alla fine». Non serve
/// nessun campo nuovo nei record: la copertura si ricava dal prezzo finale,
/// vedi <see cref="AuctionIntegrity.ObservedCoverage(double,int)"/>. Vale
/// quindi anche per lo storico raccolto prima che questa regola esistesse.</para>
/// </summary>
public double? MinObservedCoverage { get; set; }
/// <summary>Aste chiuse prima di questa data. Null = nessun limite.</summary>
public DateTime? OlderThan { get; set; }
}
/// <summary>Quante righe cadrebbero, e per quale motivo.</summary>
public sealed record CleanupReport(
int Total,
int Removed,
IReadOnlyDictionary<string, int> ByReason)
{
public int Kept => Total - Removed;
}
/// <summary>
/// Le aste da togliere secondo le regole indicate, col motivo di ciascuna.
/// <b>Non modifica nulla</b>: serve prima a mostrare il conto all'utente, e poi —
/// con la stessa risposta — a fare la rimozione. Contare con una regola e cancellare
/// con un'altra è il modo classico di far sparire dati che nessuno voleva perdere.
/// </summary>
public static Dictionary<CompletedAuctionRecord, string> Select(
IReadOnlyList<CompletedAuctionRecord> records, CleanupOptions options)
{
var doomed = new Dictionary<CompletedAuctionRecord, string>();
if (records == null || options == null) return doomed;
void Mark(CompletedAuctionRecord r, string reason)
{
if (!doomed.ContainsKey(r)) doomed[r] = reason;
}
if (options.RemoveDuplicates)
{
// Si tiene la più recente per id: è quella riscritta per ultima, quindi
// quella che ha visto la fine dell'asta.
foreach (var group in records
.Where(r => !string.IsNullOrWhiteSpace(r.AuctionId))
.GroupBy(r => r.AuctionId))
{
foreach (var extra in group.OrderByDescending(r => r.EndedAt).Skip(1))
Mark(extra, "doppione");
}
}
foreach (var r in records)
{
if (options.RemoveWithoutPrice && r.FinalPrice <= 0)
Mark(r, "senza prezzo finale");
if (options.RemoveWithoutWinner && string.IsNullOrWhiteSpace(r.Winner))
Mark(r, "senza vincitore");
if (options.RemoveWithoutWinnerBids && !r.WinnerBidsUsed.HasValue)
Mark(r, "senza puntate del vincitore");
if (options.RemoveInconsistentBids && r.WinnerBidsUsed.HasValue &&
!AuctionIntegrity.CheckWinnerBids(r).IsTrustworthy)
Mark(r, "puntate incoerenti col prezzo");
if (options.RemoveWithoutValue && r.BuyNowPrice is not > 0)
Mark(r, "senza valore del prodotto");
if (options.MinObservedCoverage is { } soglia)
{
// Copertura non calcolabile = asta senza prezzo finale: qui vale come
// "non seguita", perché non c'è modo di dire il contrario.
var copertura = AuctionIntegrity.ObservedCoverage(r);
if (copertura is null || copertura.Value < soglia)
Mark(r, "seguita solo in parte");
}
if (options.OlderThan is { } limite && r.EndedAt < limite)
Mark(r, "più vecchia del limite");
}
return doomed;
}
/// <summary>Il conto, senza toccare nulla.</summary>
public static CleanupReport Preview(
IReadOnlyList<CompletedAuctionRecord> records, CleanupOptions options)
{
var doomed = Select(records, options);
var byReason = doomed.Values
.GroupBy(v => v)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);
return new CleanupReport(records?.Count ?? 0, doomed.Count, byReason);
}
/// <summary>Applica la pulizia restituendo le righe da conservare, in ordine.</summary>
public static List<CompletedAuctionRecord> Apply(
IReadOnlyList<CompletedAuctionRecord> records, CleanupOptions options)
{
var doomed = Select(records, options);
return records.Where(r => !doomed.ContainsKey(r)).ToList();
}
}
}