# Señal vs ruido en /matriz — metodología y porqué

> Cómo la matriz distingue lo que es **señal** (mirar primero) de lo que es **ruido** o
> **normal para su tipo**. Deriva de `pipeline/senal_matriz.py` (cómputo) + `pipeline/scoring.py`
> (pesos, fuente única). La matriz **no calcula**: solo pinta la `senal{}` que trae cada fila.
> Alineado con `CLAUDE.md §2/§5` y `reglas/scoring-priorizacion.md`. "Señal de revisión, no prueba."

---

## El problema que resuelve

Antes la matriz **ordenaba por `valor` crudo** y marcaba "Revisión prioritaria" a **todo** lo de
régimen especial. Consecuencia: lo que es estructural o de mandato se pintaba rojo por lo que **es**,
no por una anomalía. Ejemplos reales del snapshot:

- **Hocol S.A** (operador petrolero del grupo Ecopetrol) — 3 contratos de ~8,5 billones cada uno
  (cañoneo, lodos, transporte de crudo) encabezaban el ranking. Pero Hocol contrata **todo** en
  régimen especial por su naturaleza jurídica: no es anomalía, es su régimen.
- **Coljuegos** concentra pagos en un operador **porque su función es pagar premios**.

Regla de fondo: **magnitud ≠ relevancia**. Concentración, valor y régimen especial son *features*
(rasgos), no *señales*. La señal es el **residual**: cuánto se aleja una fila de lo **esperado para
una entidad/objeto de su tipo**.

---

## Eje 2 — Qué se mide (este corte)

Dos señales que ya estaban en el dato pero no se usaban:

### S1 · Déficit de competencia
Un procedimiento que **debía competir** (licitación, concurso, selección abreviada) adjudicado con
**0–1 oferente** → señal de pliego a la medida / desierto dirigido.
- Solo cuenta en **adjudicados**. En fase "Publicado" `oferentes=0` es la **fase**, no señal
  (aplica a 367/393 procesos en curso — sería un falso positivo masivo). → ruido de fase, excluido.
- Magnitud = déficit contra `SENAL_OFERENTES_ESPERADO` (=3): 0 oferentes→100, 1→67, 2→33, 3+→0.
- Dato: `oferentes` presente en 297/297 adjudicados. Rinde ~122 casos con señal (INVIAS licitando
  un puente con 1 oferente, MinSalud, MinComercio, CNE…).

### S2 · Mismatch modalidad↔objeto
Un objeto cuya **categoría UNSPSC normalmente se contrata compitiendo**, aquí cerrado por
**directa/régimen especial** → señal de competencia evitada.
- Baseline por categoría UNSPSC (familia de 4 dígitos): fracción del universo que compite.
- **Se apaga en entidades estructurales** (ver abajo): para un operador petrolero el régimen
  especial es su naturaleza aunque el objeto caiga en una familia que otras entidades sí compiten.

### S3 · Off-mandate (fuera de mandato)
Objeto **fuera del objeto-mix propio de la entidad**. El baseline aquí NO es el universo sino la
entidad consigo misma: detecta que compra algo ajeno a su función. Es el complemento del S2 apagado
y **el único que sí aplica a entidades estructurales** (Coljuegos comprando obra civil, un operador
petrolero comprando publicidad).
- Se define **solo en la cola** que importa: la entidad tiene un mandato claro (su categoría líder
  concentra ≥`SENAL_MANDATO_CONC` del valor) **y** este objeto es marginal para ella
  (≤`SENAL_MANDATO_MARGINAL`). Fuera de esa cola → `None` (es su operación normal, no señal) para no
  inundar el driver.
- `n/d` si la entidad tiene <`SENAL_MANDATO_MIN` contratos (no se inventa un mandato con 2 contratos).
- Rinde casos como el Fondo de Gestión del Riesgo (mandato = transferencias) comprando servicios de
  facultades de ingeniería, o la ANT (agencia de tierras) comprando consultoría jurídica.

### Estructural = "normal para su tipo"
`es_estructural` marca las entidades donde régimen especial es su **naturaleza**, por dos vías:
1. **curado** — `tipo_organismo ∈ {régimen especial, sociedad de economía mixta, empresa industrial
   y comercial}` según `live/organigrama.json`;
2. **por dato** — ≥`SENAL_ESTRUCT_SHARE` (90%) de sus contratos son no-competitivos, con ≥2
   contratos. Esta vía es la que atrapa a **Hocol/Coljuegos**, que **no** están en el organigrama.

Efecto: Hocol pasa de encabezar el ranking a `senal=None` + badge "Normal para su tipo".

### Diferidas (con caveat explícito — no cap silencioso)
- **Fragmentación**: vive en la cola larga (contratos chicos bajo umbral), invisible en el top-N por
  valor → necesita el universo completo en vivo.
- **Auto-baseline temporal** ("¿concentró más que su propio pasado?"): SECOP II arranca ~2025 →
  ventana histórica corta; madura con el tiempo.

---

## Eje 3 — Cómo se puntúa

Por cada señal se emiten **dos números** distintos, a propósito:

1. **Percentil dentro del grupo de pares** (posición) — vía `organigrama._percentil_fn` (rango
   fraccional medio). Es "¿dónde estoy en la fila de los míos?".
2. **Desvío robusto MAD** (magnitud) — `0.6745·(x−mediana)/MAD`. Es "¿cuán raro?".

### Por qué MAD y no z-score
El dinero público tiene **cola pesada** (unos pocos techos de 8 billones). El z-score clásico
`(x−μ)/σ` **miente** aquí: esos pocos gigantes inflan σ y aplastan todo lo demás a z≈0. El MAD usa
medianas → los outliers que justamente buscamos **no** rompen la escala.

### Por qué percentil por pares y no umbral absoluto
Un umbral fijo ("concentración >70% = rojo") no discrimina: pinta rojo a media base. El percentil
**dentro del grupo de pares** hace que Coljuegos, comparado con "entidades como Coljuegos", caiga en
la mediana y baje solo — sin lista curada. (Misma regla que el índice de riesgo del organigrama/mapa.)

### El compuesto (lo que ORDENA)
```
score  = round(media de los percentiles de las señales disponibles)   # "cuán anómalo vs pares" → color/badge
orden  = score × materialidad                                          # lo que ordena la matriz
materialidad = log10(1+COP) / log10(1+SENAL_COP_MAX_REF)               # [0,1], evita que un megacontrato aplaste la cola
```
`SENAL_COP_MAX_REF = 5 billones COP` (calibración del bloque, `scoring-priorizacion.md`).

### Drivers separados (regla de honestidad)
El compuesto **ordena**, pero cada fila muestra **qué señal la subió** (`driver` = componente de mayor
percentil) y su `esperado_txt`. Nunca se colapsa a un número opaco: si no puedes decir *por qué*
importa, no es señal, es un número.

### `n/d` en universo chico — sin inventar
Si el grupo de pares tiene <`SENAL_MIN_PARES` (5) contratos, la señal queda `n/d` ("sin contraste"),
**no 0** (CLAUDE.md §5). El `cobertura` de cada fila declara qué **no** se pudo medir y por qué —
sin cap silencioso.

### Descartado: ML no supervisado
Un isolation-forest encontraría combinaciones raras, pero rompe la trazabilidad/explicabilidad que
exige `CLAUDE.md §2`. Sirve, si acaso, como **generador de hipótesis** secundario — nunca como el
score que se muestra.

---

## Contrato de salida (`senal{}` por fila)

```jsonc
"senal": {
  "score": 93,                 // 0–100, media de percentiles de pares (color/badge). null si n/d.
  "orden": 82.3,               // score × materialidad. Llave de orden de la matriz.
  "materialidad": 0.885,       // [0,1] log-normalizada.
  "driver": "competencia",     // señal dominante ("competencia" | "modalidad_objeto" | null).
  "componentes": {             // cada señal con su percentil y su MAD.
    "competencia": {"pctil": 93.0, "mad": null, "oferentes": 1}
  },
  "es_estructural": false,     // true → "Normal para su tipo".
  "esperado_txt": "1 oferente(s) en 'Licitación pública' (esperado ≥3) — competencia débil",
  "cobertura": ""              // qué NO se pudo medir (no cap silencioso).
}
```

## Tunables (en `pipeline/scoring.py`, fuente única)

| Constante | Valor | Qué controla |
|---|---|---|
| `SENAL_OFERENTES_ESPERADO` | 3 | oferentes "sanos" esperados en un proceso competitivo |
| `SENAL_MIN_PARES` | 5 | mínimo de pares para dar percentil (si no, `n/d`) |
| `SENAL_COP_MAX_REF` | 5e12 | referencia de materialidad log del bloque matriz |
| `SENAL_ESTRUCT_SHARE` | 0.90 | umbral de no-competitivo para declarar una entidad estructural |
| `SENAL_MANDATO_MIN` | 5 | mínimo de contratos de la entidad para tener perfil de mandato (si no, off-mandate `n/d`) |
| `SENAL_MANDATO_CONC` | 0.50 | la categoría líder debe pesar ≥50% del valor de la entidad para hablar de "mandato claro" |
| `SENAL_MANDATO_MARGINAL` | 0.15 | el objeto debe ser ≤15% del valor de la entidad para contar como "fuera de mandato" |
| `MODALIDADES_COMPETITIVAS_KW` | licitaci, concurso, subasta, abreviada | qué modalidad "debía competir" |
| `MODALIDADES_NO_COMPETITIVAS_KW` | directa, régimen especial | qué modalidad cierra la competencia |

## Reproducir
```bash
python3 -m pipeline.senal_matriz      # re-procesa live/procesos.json + live/contratos.json (sin red)
```
En el cron va dentro de `pipeline/run.py`, después de escribir procesos + contratos + organigrama.
