Saltar a contenido

Modelo de Datos: Notificaciones Inteligentes

Identificador: MDL-NTF-001 Version: 1.0.0 Fecha: 2025-12-07 Autor: SpecQueen Technical Division Modulo Funcional: MTS-NTF-001


1. Resumen

Este documento define el modelo de datos para el sistema de notificaciones inteligentes de MedTime. Este modulo es 100% OFFLINE - todo el procesamiento ocurre en el dispositivo del usuario.

Principio Fundamental: Los datos de patrones de comportamiento NUNCA salen del dispositivo. El servidor solo almacena blobs cifrados para backup/sync entre dispositivos del mismo usuario.

2. Arquitectura de Datos

┌─────────────────────────────────────────────────────────────────┐
│              ARQUITECTURA 100% OFFLINE                           │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  CLIENTE (100% del procesamiento)                               │
│  ├── cli_pattern_data        → Eventos de respuesta a alertas   │
│  ├── cli_detected_patterns   → Patrones identificados           │
│  ├── cli_suggestions         → Sugerencias generadas            │
│  ├── cli_ml_model_state      → Estado del modelo ML local       │
│  └── cli_insights_cache      → Cache de insights calculados     │
│                                                                  │
│  ML LOCAL                                                        │
│  ├── iOS: CoreML (Random Forest, ~500KB)                        │
│  └── Android: TensorFlow Lite (Random Forest, ~500KB)           │
│                                                                  │
│  SERVIDOR (Solo backup E2E - opcional)                          │
│  └── srv_encrypted_blobs     → Blobs opacos para sync           │
│      entity_type: 'notification_patterns'                        │
│                                                                  │
│  GARANTIA DE PRIVACIDAD                                          │
│  - Servidor NUNCA ve datos de comportamiento                    │
│  - Todo analisis es local                                        │
│  - Sync es opcional (Pro/Perfect)                               │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

3. Clasificacion de Datos

Entidad Clasificacion PHI/PII Sync Notas
cli_pattern_data LOCAL_ONLY No (anonimizado) No NUNCA sale del dispositivo
cli_detected_patterns LOCAL_ONLY No No NUNCA sale del dispositivo
cli_suggestions SYNCED_E2E No Si Solo config, no datos
cli_ml_model_state LOCAL_ONLY No No Modelo entrenado localmente
cli_insights_cache LOCAL_ONLY No No Cache temporal

IMPORTANTE: A diferencia de otros modulos, las entidades de patrones son LOCAL_ONLY y no tienen contraparte en servidor. El servidor solo ve blobs opacos si el usuario decide hacer backup.


4. Modelos del Cliente

4.1. cli_pattern_data (iOS - Swift/Realm)

Almacena eventos de respuesta para analisis de patrones.

4.1.1. Especificacion de medication_hash

El campo medication_hash se utiliza para agrupar patrones por medicamento sin revelar el ID real. Para prevenir ataques de rainbow table, se usa HMAC con salt unico por usuario.

Algoritmo:

medication_hash = HMAC-SHA256(user_salt, medication_id)

Donde:
- user_salt: Salt de 256 bits unico por usuario, generado al crear la cuenta
- medication_id: UUID del medicamento
- Resultado: String hexadecimal de 64 caracteres

Generacion del Salt:

// iOS
let userSalt = SymmetricKey(size: .bits256)
// Almacenar en Keychain asociado al usuario
// Android
val userSalt = ByteArray(32).also { SecureRandom().nextBytes(it) }
// Almacenar en EncryptedSharedPreferences

Razon de Seguridad:

Sin salt, un atacante con acceso a la base de datos podria:

  1. Generar hashes de todos los medication_id conocidos
  2. Comparar con medication_hash almacenados
  3. Determinar que medicamentos usa el usuario

Con salt unico por usuario, el atacante necesitaria el salt (protegido en Keychain/Keystore) para correlacionar los hashes.

// ============================================================
// MODELO: PatternData
// Descripcion: Eventos de respuesta a alertas para analisis
// Almacenamiento: Realm cifrado
// Sync: NUNCA - LOCAL_ONLY
// Retencion: 90 dias
// ============================================================

import RealmSwift

class PatternData: Object, Identifiable {
    // Identificadores
    @Persisted(primaryKey: true) var id: String = UUID().uuidString
    @Persisted var alertId: String = ""  // Referencia a la alerta

    // Contexto temporal
    @Persisted var dayOfWeek: Int = 0    // 0=Dom, 1=Lun, ... 6=Sab
    @Persisted var hourScheduled: Int = 0  // 0-23
    @Persisted var hourActual: Int?        // Hora real de respuesta

    // Respuesta del usuario
    @Persisted var responseType: String = ""  // taken, snoozed, skipped
    @Persisted var responseTimeSeconds: Int?  // Tiempo desde alerta hasta accion
    @Persisted var snoozeCount: Int = 0       // Veces que pospuso

    // Contexto del dispositivo
    @Persisted var deviceState: String?   // active, locked, background
    @Persisted var wasOffline: Bool = false

    // Medicamento (ID anonimizado, no nombre)
    @Persisted var medicationHash: String = ""  // SHA-256 del medication_id

    // Timestamp
    @Persisted var timestamp: Date = Date()

    // Indices para queries eficientes
    // dayOfWeek, hourScheduled, responseType

    // Computed: Fue respuesta rapida?
    var wasQuickResponse: Bool {
        guard let seconds = responseTimeSeconds else { return false }
        return seconds < 300  // Menos de 5 minutos
    }
}

// Extension para limpieza de datos antiguos
extension PatternData {
    static func cleanOldData(in realm: Realm, olderThan days: Int = 90) {
        let cutoff = Calendar.current.date(byAdding: .day, value: -days, to: Date())!
        let oldData = realm.objects(PatternData.self).filter("timestamp < %@", cutoff)
        try? realm.write {
            realm.delete(oldData)
        }
    }
}

4.2. cli_pattern_data (Android - Kotlin/Room)

// ============================================================
// MODELO: PatternData
// Descripcion: Eventos de respuesta a alertas para analisis
// Almacenamiento: Room cifrado (SQLCipher)
// Sync: NUNCA - LOCAL_ONLY
// Retencion: 90 dias
// ============================================================

package com.medtime.data.entities

import androidx.room.*
import java.time.Instant
import java.util.UUID

@Entity(
    tableName = "cli_pattern_data",
    indices = [
        Index(value = ["day_of_week", "hour_scheduled"]),
        Index(value = ["medication_hash"]),
        Index(value = ["timestamp"]),
        Index(value = ["response_type"])
    ]
)
data class PatternData(
    @PrimaryKey
    @ColumnInfo(name = "id")
    val id: String = UUID.randomUUID().toString(),

    @ColumnInfo(name = "alert_id")
    val alertId: String = "",

    // Contexto temporal
    @ColumnInfo(name = "day_of_week")
    val dayOfWeek: Int = 0,  // 0=Dom ... 6=Sab

    @ColumnInfo(name = "hour_scheduled")
    val hourScheduled: Int = 0,

    @ColumnInfo(name = "hour_actual")
    val hourActual: Int? = null,

    // Respuesta
    @ColumnInfo(name = "response_type")
    val responseType: ResponseType = ResponseType.TAKEN,

    @ColumnInfo(name = "response_time_seconds")
    val responseTimeSeconds: Int? = null,

    @ColumnInfo(name = "snooze_count")
    val snoozeCount: Int = 0,

    // Contexto dispositivo
    @ColumnInfo(name = "device_state")
    val deviceState: DeviceState? = null,

    @ColumnInfo(name = "was_offline")
    val wasOffline: Boolean = false,

    // Medicamento anonimizado (con salt por usuario)
    // Hash = HMAC-SHA256(user_salt, medication_id)
    // Salt unico por usuario previene rainbow table attacks
    // Ver seccion 4.1.1 para especificacion de hashing
    @ColumnInfo(name = "medication_hash")
    val medicationHash: String = "",

    // Timestamp
    @ColumnInfo(name = "timestamp")
    val timestamp: Instant = Instant.now()
) {
    val wasQuickResponse: Boolean
        get() = responseTimeSeconds?.let { it < 300 } ?: false
}

enum class ResponseType {
    TAKEN, SNOOZED, SKIPPED
}

enum class DeviceState {
    ACTIVE, LOCKED, BACKGROUND
}

// DAO para PatternData
@Dao
interface PatternDataDao {
    @Query("SELECT * FROM pattern_data WHERE timestamp > :since ORDER BY timestamp DESC")
    suspend fun getDataSince(since: Instant): List<PatternData>

    @Query("SELECT * FROM pattern_data WHERE medication_hash = :hash ORDER BY timestamp DESC LIMIT :limit")
    suspend fun getByMedication(hash: String, limit: Int = 100): List<PatternData>

    @Query("SELECT day_of_week, COUNT(*) as total, " +
           "SUM(CASE WHEN response_type = 'SKIPPED' THEN 1 ELSE 0 END) as skipped " +
           "FROM pattern_data WHERE timestamp > :since GROUP BY day_of_week")
    suspend fun getDayStats(since: Instant): List<DayStatResult>

    @Query("DELETE FROM pattern_data WHERE timestamp < :cutoff")
    suspend fun deleteOlderThan(cutoff: Instant): Int

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(data: PatternData)
}

data class DayStatResult(
    @ColumnInfo(name = "day_of_week") val dayOfWeek: Int,
    @ColumnInfo(name = "total") val total: Int,
    @ColumnInfo(name = "skipped") val skipped: Int
)

4.3. cli_detected_patterns

// iOS - Swift/Realm
// ============================================================
// MODELO: DetectedPattern
// Descripcion: Patrones identificados por el analizador
// Almacenamiento: Realm cifrado
// Sync: NUNCA - LOCAL_ONLY
// ============================================================

class DetectedPattern: Object, Identifiable {
    @Persisted(primaryKey: true) var id: String = UUID().uuidString

    // Medicamento (anonimizado)
    @Persisted var medicationHash: String = ""

    // Tipo de patron
    @Persisted var patternType: String = ""
    // Tipos: schedule_consistent, schedule_variable, problematic_day,
    //        slow_responder, serial_snoozer, irregular

    // Datos del patron
    @Persisted var patternData: PatternDetails?

    // Confianza (0.0 - 1.0)
    @Persisted var confidence: Double = 0.0

    // Accion sugerida
    @Persisted var suggestedAction: String?
    // Acciones: adjust_time, add_pre_alert, add_reminder, special_day_alert

    @Persisted var suggestedValue: String?  // ej: "07:30" para adjust_time

    // Estado
    @Persisted var isActive: Bool = true
    @Persisted var userResponse: String?  // accepted, rejected, pending

    // Timestamps
    @Persisted var detectedAt: Date = Date()
    @Persisted var respondedAt: Date?
    @Persisted var expiresAt: Date?  // Patron expira si no se usa

    // Recalculo
    @Persisted var lastRecalculatedAt: Date = Date()
    @Persisted var dataPointsUsed: Int = 0
}

// Embedded: Detalles del patron
class PatternDetails: EmbeddedObject {
    // Para SCHEDULE_CONSISTENT
    @Persisted var averageHour: Int?
    @Persisted var averageMinute: Int?
    @Persisted var standardDeviationMinutes: Double?

    // Para SCHEDULE_VARIABLE
    @Persisted var rangeStartHour: Int?
    @Persisted var rangeEndHour: Int?

    // Para PROBLEMATIC_DAY
    @Persisted var problematicDays: List<Int>  // 0=Dom...6=Sab
    @Persisted var skipRateNormal: Double?
    @Persisted var skipRateProblematic: Double?

    // Para SLOW_RESPONDER
    @Persisted var averageResponseSeconds: Int?
    @Persisted var p90ResponseSeconds: Int?

    // Para SERIAL_SNOOZER
    @Persisted var snoozeRate: Double?
    @Persisted var averageSnoozeCount: Double?
}

4.4. cli_suggestions

// iOS - Swift/Realm
// ============================================================
// MODELO: Suggestion
// Descripcion: Sugerencias generadas para el usuario
// Almacenamiento: Realm cifrado
// Sync: E2E (solo config de sugerencias aceptadas)
// ============================================================

class Suggestion: Object, Identifiable {
    @Persisted(primaryKey: true) var id: String = UUID().uuidString

    // Patron que genero la sugerencia
    @Persisted var patternId: String = ""

    // Tipo de sugerencia
    @Persisted var suggestionType: String = ""
    // Tipos: adjust_schedule, add_pre_alert, add_reinforcement,
    //        special_day_reminder, change_sound, enable_escalation

    // Contenido
    @Persisted var title: String = ""
    @Persisted var body: String = ""
    @Persisted var detailedExplanation: String?

    // Datos de la sugerencia
    @Persisted var currentValue: String?   // ej: "08:00"
    @Persisted var suggestedValue: String? // ej: "07:30"
    @Persisted var affectedMedicationHash: String?

    // Estadisticas mostradas al usuario
    @Persisted var statsShown: SuggestionStats?

    // Confianza
    @Persisted var confidence: Double = 0.0

    // Estado
    @Persisted var status: String = "pending"
    // Estados: pending, shown, accepted, rejected, expired, applied

    // Timing
    @Persisted var createdAt: Date = Date()
    @Persisted var showAfter: Date?  // No mostrar antes de esta fecha
    @Persisted var shownAt: Date?
    @Persisted var respondedAt: Date?
    @Persisted var appliedAt: Date?
    @Persisted var expiresAt: Date?

    // Cooldown (no repetir si rechazada)
    @Persisted var cooldownDays: Int = 30

    // Sync (solo sugerencias aplicadas)
    @Persisted var syncStatus: String = "local"  // local, synced
    @Persisted var version: Int = 1
}

// Embedded: Stats mostradas en la sugerencia
class SuggestionStats: EmbeddedObject {
    @Persisted var dataPointsAnalyzed: Int = 0
    @Persisted var periodDays: Int = 0
    @Persisted var averageValue: String?
    @Persisted var improvementEstimate: String?
}

// Reglas de negocio:
// - Max 1 sugerencia por dia
// - Confianza minima 80%
// - Cooldown 30 dias si rechazada
// - Expira en 7 dias si no responde

4.5. cli_ml_model_state

// iOS - Swift/Realm
// ============================================================
// MODELO: MLModelState
// Descripcion: Estado del modelo de ML local
// Almacenamiento: Realm cifrado
// Sync: NUNCA - LOCAL_ONLY
// ============================================================

class MLModelState: Object {
    @Persisted(primaryKey: true) var id: String = "ml_state"

    // Version del modelo
    @Persisted var modelVersion: String = "1.0.0"
    @Persisted var frameworkVersion: String = ""  // CoreML/TFLite version

    // Estado de entrenamiento
    @Persisted var isReadyForPrediction: Bool = false
    @Persisted var minDataPointsRequired: Int = 140  // ~14 dias * 10 tomas
    @Persisted var currentDataPoints: Int = 0

    // Metricas del modelo
    @Persisted var lastTrainedAt: Date?
    @Persisted var trainingDataPoints: Int = 0
    @Persisted var validationAccuracy: Double?
    @Persisted var precision: Double?
    @Persisted var recall: Double?
    @Persisted var f1Score: Double?

    // Features usadas
    @Persisted var featuresUsed: List<String>
    // day_of_week (7), hour_of_day (1), days_since_last_skip (1),
    // current_streak (1), historical_skip_rate (1), avg_response_time (1)
    // Total: 12 features

    // Predicciones recientes
    @Persisted var predictionsLast7Days: Int = 0
    @Persisted var correctPredictions: Int = 0
    @Persisted var falsePositives: Int = 0
    @Persisted var falseNegatives: Int = 0

    // Proximo entrenamiento
    @Persisted var nextTrainingScheduled: Date?
    @Persisted var retrainingIntervalDays: Int = 7

    // Timestamps
    @Persisted var createdAt: Date = Date()
    @Persisted var updatedAt: Date = Date()

    // Computed
    var predictionAccuracy: Double {
        guard predictionsLast7Days > 0 else { return 0 }
        return Double(correctPredictions) / Double(predictionsLast7Days)
    }
}

4.6. cli_insights_cache

// iOS - Swift/Realm
// ============================================================
// MODELO: InsightsCache
// Descripcion: Cache de insights calculados para dashboard
// Almacenamiento: Realm cifrado
// Sync: NUNCA - LOCAL_ONLY
// TTL: Se recalcula diariamente
// ============================================================

class InsightsCache: Object {
    @Persisted(primaryKey: true) var id: String = "insights"

    // Periodo de datos
    @Persisted var periodStart: Date = Date()
    @Persisted var periodEnd: Date = Date()
    @Persisted var periodDays: Int = 7

    // Resumen general
    @Persisted var totalAlerts: Int = 0
    @Persisted var takenOnTime: Int = 0
    @Persisted var takenLate: Int = 0
    @Persisted var snoozed: Int = 0
    @Persisted var skipped: Int = 0

    // Adherencia
    @Persisted var adherenceRate: Double = 0.0
    @Persisted var onTimeRate: Double = 0.0

    // Tiempo de respuesta
    @Persisted var avgResponseSeconds: Int = 0
    @Persisted var medianResponseSeconds: Int = 0

    // Por dia de la semana
    @Persisted var dayStats: List<DayInsight>

    // Mejor y peor dia
    @Persisted var bestDayOfWeek: Int?
    @Persisted var bestDayRate: Double?
    @Persisted var worstDayOfWeek: Int?
    @Persisted var worstDayRate: Double?

    // Rachas
    @Persisted var currentStreak: Int = 0
    @Persisted var longestStreak: Int = 0
    @Persisted var streakStartDate: Date?

    // Prediccion para manana
    @Persisted var tomorrowRiskScore: Double?
    @Persisted var tomorrowRiskFactors: List<String>

    // Cache validity
    @Persisted var calculatedAt: Date = Date()
    @Persisted var expiresAt: Date = Date()

    var isExpired: Bool {
        return Date() > expiresAt
    }
}

// Embedded: Stats por dia de la semana
class DayInsight: EmbeddedObject {
    @Persisted var dayOfWeek: Int = 0  // 0=Dom...6=Sab
    @Persisted var totalAlerts: Int = 0
    @Persisted var takenCount: Int = 0
    @Persisted var skippedCount: Int = 0
    @Persisted var adherenceRate: Double = 0.0
    @Persisted var avgResponseSeconds: Int = 0
    @Persisted var isProblematic: Bool = false
}

4.7. cli_prediction_log

// iOS - Swift/Realm
// ============================================================
// MODELO: PredictionLog
// Descripcion: Log de predicciones para evaluar modelo
// Almacenamiento: Realm cifrado
// Sync: NUNCA - LOCAL_ONLY
// Retencion: 30 dias
// ============================================================

class PredictionLog: Object, Identifiable {
    @Persisted(primaryKey: true) var id: String = UUID().uuidString

    // Prediccion
    @Persisted var alertId: String = ""
    @Persisted var medicationHash: String = ""
    @Persisted var predictedSkipProbability: Double = 0.0
    @Persisted var predictionThreshold: Double = 0.6

    // Fue clasificado como riesgo?
    @Persisted var wasHighRisk: Bool = false

    // Alerta preventiva enviada?
    @Persisted var preventiveAlertSent: Bool = false
    @Persisted var preventiveAlertTime: Date?

    // Resultado real
    @Persisted var actualOutcome: String?  // taken, skipped, snoozed
    @Persisted var outcomeRecordedAt: Date?

    // Evaluacion
    @Persisted var wasCorrectPrediction: Bool?
    // true si: (wasHighRisk && skipped) || (!wasHighRisk && taken)

    // Features usadas (para debug)
    @Persisted var featuresSnapshot: String?  // JSON de features

    // Timestamp
    @Persisted var predictedAt: Date = Date()
}

// Extension para metricas
extension PredictionLog {
    static func calculateMetrics(in realm: Realm, lastDays: Int = 7) -> PredictionMetrics {
        let cutoff = Calendar.current.date(byAdding: .day, value: -lastDays, to: Date())!
        let logs = realm.objects(PredictionLog.self)
            .filter("predictedAt >= %@ AND actualOutcome != nil", cutoff)

        let total = logs.count
        let correct = logs.filter("wasCorrectPrediction == true").count

        let truePositives = logs.filter("wasHighRisk == true AND actualOutcome == 'skipped'").count
        let falsePositives = logs.filter("wasHighRisk == true AND actualOutcome != 'skipped'").count
        let falseNegatives = logs.filter("wasHighRisk == false AND actualOutcome == 'skipped'").count

        let precision = truePositives > 0 ?
            Double(truePositives) / Double(truePositives + falsePositives) : 0
        let recall = truePositives > 0 ?
            Double(truePositives) / Double(truePositives + falseNegatives) : 0

        return PredictionMetrics(
            total: total,
            correct: correct,
            accuracy: total > 0 ? Double(correct) / Double(total) : 0,
            precision: precision,
            recall: recall,
            f1Score: precision + recall > 0 ?
                2 * precision * recall / (precision + recall) : 0
        )
    }
}

struct PredictionMetrics {
    let total: Int
    let correct: Int
    let accuracy: Double
    let precision: Double
    let recall: Double
    let f1Score: Double
}

5. Android Room Equivalents

5.1. DetectedPattern

@Entity(
    tableName = "cli_detected_patterns",
    indices = [
        Index(value = ["medication_hash"]),
        Index(value = ["pattern_type"]),
        Index(value = ["is_active"])
    ]
)
data class DetectedPattern(
    @PrimaryKey
    val id: String = UUID.randomUUID().toString(),

    @ColumnInfo(name = "medication_hash")
    val medicationHash: String = "",

    @ColumnInfo(name = "pattern_type")
    val patternType: PatternType = PatternType.IRREGULAR,

    @Embedded(prefix = "details_")
    val patternDetails: PatternDetails? = null,

    @ColumnInfo(name = "confidence")
    val confidence: Double = 0.0,

    @ColumnInfo(name = "suggested_action")
    val suggestedAction: SuggestedAction? = null,

    @ColumnInfo(name = "suggested_value")
    val suggestedValue: String? = null,

    @ColumnInfo(name = "is_active")
    val isActive: Boolean = true,

    @ColumnInfo(name = "user_response")
    val userResponse: UserResponse? = null,

    @ColumnInfo(name = "detected_at")
    val detectedAt: Instant = Instant.now(),

    @ColumnInfo(name = "responded_at")
    val respondedAt: Instant? = null,

    @ColumnInfo(name = "last_recalculated_at")
    val lastRecalculatedAt: Instant = Instant.now(),

    @ColumnInfo(name = "data_points_used")
    val dataPointsUsed: Int = 0
)

enum class PatternType {
    SCHEDULE_CONSISTENT,
    SCHEDULE_VARIABLE,
    PROBLEMATIC_DAY,
    SLOW_RESPONDER,
    SERIAL_SNOOZER,
    IRREGULAR
}

enum class SuggestedAction {
    ADJUST_TIME,
    ADD_PRE_ALERT,
    ADD_REMINDER,
    SPECIAL_DAY_ALERT
}

enum class UserResponse {
    ACCEPTED, REJECTED, PENDING
}

data class PatternDetails(
    // Schedule patterns
    val averageHour: Int? = null,
    val averageMinute: Int? = null,
    val standardDeviationMinutes: Double? = null,
    val rangeStartHour: Int? = null,
    val rangeEndHour: Int? = null,

    // Problematic day
    val problematicDays: List<Int> = emptyList(),
    val skipRateNormal: Double? = null,
    val skipRateProblematic: Double? = null,

    // Response patterns
    val averageResponseSeconds: Int? = null,
    val p90ResponseSeconds: Int? = null,
    val snoozeRate: Double? = null,
    val averageSnoozeCount: Double? = null
)

6. Algoritmos de Analisis

6.1. Detector de Patrones de Horario

// PatternAnalyzer.swift

class PatternAnalyzer {
    private let realm: Realm
    private let minimumDataPoints = 10

    func analyzeSchedulePattern(for medicationHash: String) -> DetectedPattern? {
        // Obtener datos de los ultimos 30 dias
        let cutoff = Calendar.current.date(byAdding: .day, value: -30, to: Date())!
        let data = realm.objects(PatternData.self)
            .filter("medicationHash == %@ AND timestamp >= %@ AND responseType == 'taken'",
                    medicationHash, cutoff)

        guard data.count >= minimumDataPoints else { return nil }

        // Extraer horas reales de respuesta
        let hours = data.compactMap { $0.hourActual }
        guard !hours.isEmpty else { return nil }

        // Calcular estadisticas
        let mean = Double(hours.reduce(0, +)) / Double(hours.count)
        let variance = hours.map { pow(Double($0) - mean, 2) }.reduce(0, +) / Double(hours.count)
        let stdDev = sqrt(variance)

        // Convertir a minutos para mayor precision
        let stdDevMinutes = stdDev * 60

        let pattern = DetectedPattern()
        pattern.medicationHash = medicationHash
        pattern.dataPointsUsed = data.count

        if stdDevMinutes < 10 {
            // Patron consistente (< 10 min desviacion)
            pattern.patternType = "schedule_consistent"
            pattern.confidence = min(0.95, 0.7 + (Double(data.count) / 100.0))

            let details = PatternDetails()
            details.averageHour = Int(mean)
            details.averageMinute = Int((mean - Double(Int(mean))) * 60)
            details.standardDeviationMinutes = stdDevMinutes
            pattern.patternData = details

            pattern.suggestedAction = "adjust_time"
            pattern.suggestedValue = String(format: "%02d:%02d",
                                           details.averageHour!, details.averageMinute!)

        } else if stdDevMinutes < 30 {
            // Patron variable pero dentro de rango
            pattern.patternType = "schedule_variable"
            pattern.confidence = min(0.80, 0.6 + (Double(data.count) / 150.0))

            let details = PatternDetails()
            details.rangeStartHour = Int(mean - stdDev)
            details.rangeEndHour = Int(mean + stdDev)
            pattern.patternData = details

        } else {
            // Irregular
            pattern.patternType = "irregular"
            pattern.confidence = 0.5
        }

        return pattern
    }

    func analyzeProblematicDays(for medicationHash: String) -> DetectedPattern? {
        let cutoff = Calendar.current.date(byAdding: .day, value: -30, to: Date())!
        let data = realm.objects(PatternData.self)
            .filter("medicationHash == %@ AND timestamp >= %@", medicationHash, cutoff)

        guard data.count >= minimumDataPoints else { return nil }

        // Agrupar por dia de la semana
        var dayStats: [Int: (total: Int, skipped: Int)] = [:]
        for record in data {
            let day = record.dayOfWeek
            var stats = dayStats[day] ?? (0, 0)
            stats.total += 1
            if record.responseType == "skipped" {
                stats.skipped += 1
            }
            dayStats[day] = stats
        }

        // Calcular tasas de omision
        var skipRates: [Int: Double] = [:]
        for (day, stats) in dayStats {
            if stats.total >= 3 {  // Minimo 3 puntos por dia
                skipRates[day] = Double(stats.skipped) / Double(stats.total)
            }
        }

        guard !skipRates.isEmpty else { return nil }

        let overallSkipRate = Double(data.filter { $0.responseType == "skipped" }.count) / Double(data.count)

        // Encontrar dias problematicos (>= 2x la tasa promedio)
        let problematicDays = skipRates.filter { $0.value >= overallSkipRate * 2 && $0.value > 0.2 }

        guard !problematicDays.isEmpty else { return nil }

        let pattern = DetectedPattern()
        pattern.medicationHash = medicationHash
        pattern.patternType = "problematic_day"
        pattern.confidence = min(0.85, 0.6 + (Double(data.count) / 200.0))
        pattern.dataPointsUsed = data.count

        let details = PatternDetails()
        details.problematicDays.append(objectsIn: problematicDays.keys.sorted())
        details.skipRateNormal = overallSkipRate
        details.skipRateProblematic = problematicDays.values.max()
        pattern.patternData = details

        pattern.suggestedAction = "special_day_alert"

        return pattern
    }
}

6.2. Predictor de Omision (ML Local)

// OmissionPredictor.swift

class OmissionPredictor {
    private var model: MLModel?
    private let featureExtractor = FeatureExtractor()

    init() {
        loadModel()
    }

    private func loadModel() {
        // iOS: CoreML
        guard let modelURL = Bundle.main.url(forResource: "OmissionPredictor",
                                              withExtension: "mlmodelc") else {
            return
        }
        model = try? MLModel(contentsOf: modelURL)
    }

    func predictOmissionRisk(
        dayOfWeek: Int,
        hourOfDay: Int,
        daysSinceLastSkip: Int,
        currentStreak: Int,
        historicalSkipRate: Double,
        avgResponseTimeSeconds: Int
    ) -> Double {
        guard let model = model else {
            // Fallback a heuristicas simples si no hay modelo
            return fallbackPrediction(
                dayOfWeek: dayOfWeek,
                historicalSkipRate: historicalSkipRate
            )
        }

        // Preparar features
        let features = featureExtractor.extract(
            dayOfWeek: dayOfWeek,
            hourOfDay: hourOfDay,
            daysSinceLastSkip: daysSinceLastSkip,
            currentStreak: currentStreak,
            historicalSkipRate: historicalSkipRate,
            avgResponseTimeSeconds: avgResponseTimeSeconds
        )

        // Ejecutar prediccion
        guard let prediction = try? model.prediction(from: features) else {
            return fallbackPrediction(dayOfWeek: dayOfWeek, historicalSkipRate: historicalSkipRate)
        }

        // Extraer probabilidad de omision
        return prediction.featureValue(for: "skipProbability")?.doubleValue ?? 0.0
    }

    private func fallbackPrediction(dayOfWeek: Int, historicalSkipRate: Double) -> Double {
        // Heuristica simple cuando no hay modelo ML
        var risk = historicalSkipRate

        // Fines de semana suelen tener mas omisiones
        if dayOfWeek == 0 || dayOfWeek == 6 {
            risk *= 1.3
        }

        return min(1.0, risk)
    }
}

class FeatureExtractor {
    func extract(
        dayOfWeek: Int,
        hourOfDay: Int,
        daysSinceLastSkip: Int,
        currentStreak: Int,
        historicalSkipRate: Double,
        avgResponseTimeSeconds: Int
    ) -> MLFeatureProvider {
        // One-hot encoding para dia de semana (7 features)
        var dayVector = [Double](repeating: 0.0, count: 7)
        dayVector[dayOfWeek] = 1.0

        // Normalizar hora (0-1)
        let normalizedHour = Double(hourOfDay) / 24.0

        // Normalizar dias desde ultimo skip (cap en 30)
        let normalizedDaysSince = min(Double(daysSinceLastSkip), 30.0) / 30.0

        // Normalizar racha (cap en 100)
        let normalizedStreak = min(Double(currentStreak), 100.0) / 100.0

        // Skip rate ya esta normalizado (0-1)

        // Normalizar tiempo de respuesta (cap en 1 hora)
        let normalizedResponse = min(Double(avgResponseTimeSeconds), 3600.0) / 3600.0

        // Crear feature provider
        return OmissionFeatures(
            dayOfWeek: dayVector,
            hourOfDay: normalizedHour,
            daysSinceLastSkip: normalizedDaysSince,
            currentStreak: normalizedStreak,
            historicalSkipRate: historicalSkipRate,
            avgResponseTime: normalizedResponse
        )
    }
}

7. Disponibilidad por Tier

Feature Free Pro Perfect
Alertas basicas Si Si Si
Recoleccion de datos No Si Si
Deteccion patrones horario No Si Si
Deteccion dias problematicos No No Si
Sugerencias de optimizacion No Si Si
Alertas predictivas (ML) No No Si
Dashboard insights basico No Si Si
Dashboard insights completo No No Si
Sync de preferencias No Si Si

8. Retencion de Datos

Tipo de Dato Retencion Razon
PatternData 90 dias Calculo de patrones
DetectedPattern Indefinido Personalizacion activa
Suggestion (aceptada) Indefinido Configuracion
Suggestion (rechazada) 30 dias Cooldown
MLModelState Indefinido Estado del modelo
InsightsCache 24 horas Cache temporal
PredictionLog 30 dias Evaluacion del modelo

9. Validaciones

// Validaciones de negocio

extension PatternAnalyzer {
    // RN-NTF-001: Minimo 14 dias de datos
    static let minimumDaysForPatterns = 14

    // RN-NTF-002: Minimo 10 tomas por medicamento
    static let minimumDataPointsPerMedication = 10

    // RN-NTF-010: Confianza minima para sugerencias
    static let minimumConfidenceForSuggestion = 0.80

    // RN-NTF-020: Riesgo minimo para alerta predictiva
    static let minimumRiskForPredictiveAlert = 0.60
}

extension Suggestion {
    // RN-NTF-011: Max 1 sugerencia por dia
    static func canShowNewSuggestion(in realm: Realm) -> Bool {
        let today = Calendar.current.startOfDay(for: Date())
        let shown = realm.objects(Suggestion.self)
            .filter("shownAt >= %@", today)
        return shown.isEmpty
    }

    // RN-NTF-012: Cooldown de 30 dias si rechazada
    static func isInCooldown(type: String, medicationHash: String, in realm: Realm) -> Bool {
        let cutoff = Calendar.current.date(byAdding: .day, value: -30, to: Date())!
        let rejected = realm.objects(Suggestion.self)
            .filter("suggestionType == %@ AND affectedMedicationHash == %@ AND status == 'rejected' AND respondedAt >= %@",
                    type, medicationHash, cutoff)
        return !rejected.isEmpty
    }
}

10. Referencias

Documento Relacion
MTS-NTF-001 Especificacion funcional
MDL-ALT-001 Modelo alertas base
MDL-MED-001 Modelo medicamentos
04-seguridad-cliente.md Cifrado local

Modelo generado por SpecQueen Technical Division - IT-05