CreaRack-SL

Servicio: Motor de Integridad (compute + gate)

Descripción

El servicio Motor de Integridad es la columna vertebral de la F2. Centraliza la lógica que:

  1. Computa el estado en vivo del radar plano↔realidad (compute_integrity()).
  2. Integra veredictos humanos (IntegrityAck) para excluir false-alarms de las métricas.
  3. Alimenta automáticamente la métrica del gate (gate_stats()).

Ubicación: racks/services/integrity.py (módulo, no clase).
Versión: v1.59.0 (motor) + v1.60.0 (veredictos + gate).


Funciones públicas

1. compute_integrity(org, fresh_days=7)

Propósito: Estado completo del radar en vivo, incluyendo el efecto de veredictos previos.

Firma:

def compute_integrity(organization, fresh_days: int = DEFAULT_FRESH_DAYS) -> dict

Parámetros:

  • organization: FK(Organization) — org a validar.
  • fresh_days: int = 7 — ventana de “visto recientemente en red” (DeviceProfile.last_seen).

Retorna:

{
    "summary": {
        "devices": 250,
        "matched_alive": 248,
        "matched_stale": 1,
        "ip_conflict": 0,
        "unobservable": 1,
        "muted": 0,
        "fidelity_pct": 99.1,
        "coverage_pct": 99.5,
        "undocumented": 3,
    },
    "racks": [
        {
            "rack_id": 1,
            "rack_name": "Rack-A",
            "fidelity_pct": 100.0,
            "coverage_pct": 100.0,
            "matched_alive": 20,
            "matched_stale": 0,
            "ip_conflict": 0,
            "unobservable": 0,
            "muted": 0,
            "findings": [
                {
                    "device_id": 42,
                    "name": "Switch-A",
                    "u_position": 10,
                    "classification": "matched_stale",
                    "reason": "Plan says present, network cannot confirm (7+ days)",
                    "evidence": {...},
                    "ack": {  # Si hay veredicto vigente
                        "verdict": "plan_updated",
                        "muted_until": "2026-07-20T10:00:00Z",
                        "note": "Ya retirado"
                    }
                },
                ...
            ]
        },
        ...
    ],
    "undocumented": [  # DeviceProfile vivos sin plano
        {
            "profile_id": 100,
            "ip": "10.0.1.50",
            "hostname": "scanner-lab",
            "vendor": "HP",
            "model": "OfficeJet",
            "device_type": "printer",
            "last_seen": "2026-07-17T14:30:00Z",
            "ack": {  # Si hay veredicto vigente
                "verdict": "false_alarm",
                "muted_until": "2026-08-16T10:00:00Z",
                "note": "Es temporal, ignorar"
            }
        },
        ...
    ]
}

Lógica:

  1. Carga veredictos vigentes:

    acks = _load_active_acks(organization, now)
    # Índice: {("device", id, kind): ack_obj, ...}
  2. Itera devices por rack:

    • Para cada Device en la org (no deletado, no template).
    • Busca DeviceProfile que le corresponda (match por IP+MAC o mejor heurística).
    • Valida que esté “vivo en red” (last_seen < 7 días).
  3. Clasifica cada device:

    • matched_alive: Hay match en ambos planos (plan + red) y vivo recientemente.
    • matched_stale: Hay match pero no se ve en red (> 7 días sin confirmación).
    • ip_conflict: Dos devices afirman la misma IP (después de resolver MACs duales, etc.).
    • unobservable: Imposible de validar (sin IPv4, sin MAC, etc.).
  4. Aplica veredictos previos:

    ack = acks.get(("device", device.id, CLASS_MATCHED_STALE))
    if ack:
        finding["ack"] = _ack_info(ack)
        if ack.verdict == VERDICT_FALSE_ALARM:
            counts["muted"] += 1  # Excluir de fidelity
        else:
            counts[CLASS_MATCHED_STALE] += 1  # Contar (plano aún diverge)
    else:
        counts[CLASS_MATCHED_STALE] += 1  # Sin veredicto
  5. Calcula métricas:

    observable = total - unobservable - muted
    fidelity_pct = 100 * matched_alive / observable
    coverage_pct = 100 * observable / total
  6. Itera DeviceProfiles sin Device (undocumented):

    • Los que estén “vivos en red” (last_seen < 7 días) y NO tengan device asignado.
    • Aplica same veredictos → excluye si false_alarm (cuarentena).
  7. Guarda snapshot diario:

    RackIntegritySnapshot.objects.get_or_create(
        organization=org,
        rack=rack,
        date=date.today(),
        defaults={"fidelity_pct": fidelity_pct, "coverage_pct": coverage_pct}
    )

2. gate_stats(org)

Propósito: Métrica de calibración del motor para el gate #202.

Firma:

def gate_stats(organization) -> dict

Retorna:

{
    "verdicts": {
        "plan_updated": 50,
        "real_change": 42,
        "false_alarm": 8,
    },
    "signal": 92,  # plan_updated + real_change
    "noise": 8,    # false_alarm
    "total": 100,
    "signal_pct": 92.0,  # Si total > 0, else None
}

Lógica:

rows = IntegrityAck.objects.filter(organization=organization) \
    .values("verdict").annotate(n=Count("id"))
by = {r["verdict"]: r["n"] for r in rows}

signal = by.get(VERDICT_PLAN_UPDATED, 0) + by.get(VERDICT_REAL_CHANGE, 0)
noise = by.get(VERDICT_FALSE_ALARM, 0)
total = signal + noise
signal_pct = 100.0 * signal / total if total else None

Interpretación:

  • signal_pct > 85% → Go para producción (motor es confiable).
  • signal_pct < 70% → NoGo (demasiado ruido, tuning requerido).
  • signal_pct vacío → aún sin veredictos (beta activa, esperando feedback).

Funciones privadas

_load_active_acks(org, now)

Carga veredictos vigentes (no mutados aún).

def _load_active_acks(organization, now) -> dict

Retorna:

{
    ("device", 42, "matched_stale"): ack_obj1,
    ("profile", 100, "undocumented"): ack_obj2,
    ...
}

Lógica:

qs = IntegrityAck.objects.filter(
    organization=organization,
    muted_until__gt=now
).order_by("created_at")

for ack in qs:
    key = ("device", ack.device_id, ack.kind) if ack.device_id else \
          ("profile", ack.profile_id, ack.kind)
    acks[key] = ack  # Si hay duplicados, el más reciente sobrescribe

Nota: Si hay varios acks para el mismo sujeto+kind, gana el más reciente (por orden de creación).

_ack_info(ack)

Serializa un IntegrityAck para respuesta JSON.

def _ack_info(ack) -> dict:
    return {
        "verdict": ack.verdict,
        "muted_until": ack.muted_until.isoformat(),
        "note": ack.note or None,
    }

_evidence(device, mip, profile, target)

Construye la cadena de evidencia de un hallazgo (qué datos llevaron a la conclusión).

def _evidence(device, mip, profile, target) -> dict:
    return {
        "device": {
            "name": device.name,
            "ip": mip.ip if mip else None,
            "mac": mip.mac if mip else None,
        },
        "profile": {
            "ip": profile.ip_address if profile else None,
            "hostname": profile.hostname if profile else None,
            "last_seen": _iso(_profile_last_seen(profile)),
        },
        "match_type": target.match_type if target else None,
    }

Constantes

# Tipos de hallazgo (cls, kind)
CLASS_MATCHED_ALIVE = "matched_alive"
CLASS_MATCHED_STALE = "matched_stale"
CLASS_IP_CONFLICT = "ip_conflict"
CLASS_UNOBSERVABLE = "unobservable"

# Veredictos (F2)
VERDICT_PLAN_UPDATED = "plan_updated"
VERDICT_REAL_CHANGE = "real_change"
VERDICT_FALSE_ALARM = "false_alarm"
VERDICTS = (VERDICT_PLAN_UPDATED, VERDICT_REAL_CHANGE, VERDICT_FALSE_ALARM)
ACK_KINDS = (CLASS_MATCHED_STALE, CLASS_IP_CONFLICT, "undocumented")

# Ventanas de silencio (días)
ACK_MUTE_DAYS = {
    VERDICT_PLAN_UPDATED: 3,
    VERDICT_REAL_CHANGE: 30,
    VERDICT_FALSE_ALARM: 30,
}

# Defaults
DEFAULT_FRESH_DAYS = 7  # DeviceProfile.last_seen < 7 días = vivo

Complejidad y performance

Queries principales

  1. compute_integrity(org):

    • Device.objects.filter(rack__organization=org, deleted_at__isnull=True) — O(devices).
    • DeviceProfile.objects.filter(organization=org) — O(profiles).
    • Match heurística — O(devices * profiles) en worst case (optimizable con índices).
    • Load acks — O(acks) con índice org+muted_until.
    • Total: O(devices * profiles) + índice opt. → ~100ms para 500 devices + 1000 profiles.
  2. gate_stats(org):

    • IntegrityAck.objects.filter(organization=org).values("verdict").annotate(Count) — O(acks) con índice.
    • Total: ~5ms.

Almacenamiento

  • IntegrityAck: 1 fila por veredicto → crece con volumen de cambios (típicamente 50-200/mes por org).
  • RackIntegritySnapshot: 1 fila/rack/día → crece O(racks * days) (típicamente 30-200 MB/año).

Integración con API y vistas

GET /api/racks/integrity/overview

@router.get("/overview")
def overview(request):
    org = require_org(request)
    result = integrity.compute_integrity(org)
    history = RackIntegritySnapshot.objects.filter(...).order_by("-date")[:30][::-1]
    return {"result": result, "history": history, "gate": integrity.gate_stats(org)}

POST /api/racks/integrity/ack

@router.post("/ack")
def ack_finding(request, data: AckIn):
    org = require_org(request)
    require_perm(request, "racks", "edit")
    ack = IntegrityAck.objects.create(...)
    return {"ok": True, "ack_id": ack.id, "gate": integrity.gate_stats(org)}

Pruebas unitarias

10 tests en tests/racks/test_integrity_api.py:

  • test_gating_without_module — 403 sin módulo activo.
  • test_compute_integrity_basic — cómputo sin veredictos.
  • test_false_alarm_excludes_fidelity — false_alarm → muted, no castiga %.
  • test_gate_stats_signal_ratio — cálculo correcto de signal_pct.
  • test_ack_silences_by_verdict — muted_until correcto (3/30/30 días).
  • test_rls_isolation — usuarios nunca ven acks cross-org.

Véase también

  • [[feature—racks—integridad-f2-radar-interno]]
  • [[entity—racks—model—integrity-ack]]
  • [[entity—racks—endpoint—integrity-overview-ack]]
  • [[entity—racks—model—rack]]
  • [[entity—network—model—device-profile]]
  • [[concept—racks—calibracion-sensor]]
  • [[decision—20260717—integrity-apagado-por-defecto]]