01 · Resumen ejecutivo

El Score que mide salud ejecutiva, no actividad

FARO Score sintetiza resultado + tensión + ejecución + evidencia + riesgo + confianza en una escala 0-100. No premia actividad; premia salud ejecutiva, ejecución validada y control real.

El motor MVP define cinco dimensiones canónicas con pesos máximos fijos (KPI 30 + Tension 25 + Execution 20 + Evidence 15 + Governance 10 = 100). Cada dimensión se calcula con lógica propia, penalizaciones, recuperaciones, severidad y confianza. La suma se clampea a [0, 100] y produce el Score final más un estado (excellent, good, attention, warning, critical, emergency).

Junto al Score, el motor calcula una segunda métrica independiente: Score Confidence. La confianza no mide si la empresa está bien; mide si la lectura es confiable. Un Score 90 con datos malos es un espejismo con branding. La confianza se compone de completitud, actualidad, consistencia, calidad de evidencia y cobertura de módulos.

Toda evaluación produce un snapshot histórico en faro.score_snapshots que conserva valor, delta vs período anterior, status, confidence, componentes desagregados, drivers principales y oportunidades de recuperación. El reporte semanal y el dashboard ejecutivo consumen estos snapshots.

El motor también produce una explicación ejecutiva controlada: top drivers de caída/mejora con código, título e impacto en puntos. La IA (FARO-AI-001) puede redactar narrativa sobre estos datos estructurados, pero no inventa, no cambia reglas y no cierra acciones. FARO calcula y gobierna; la IA redacta y explica.

Para la demo de Empresa Demo Cuyo S.A., este documento usa el ciclo canónico 74 → 66 (delta -8) con recuperación potencial +16 aplicando la decisión D7. Reemplaza el viejo 74→78 del pack inicial: hoy el Score cae por crecimiento no rentable, cobranza desfasada y acciones críticas con evidencia pendiente, y se recupera solo cerrando con evidencia aprobada.

02 · Tesis de las dos métricas

Por qué Score y Confidence son métricas separadas

Un solo número que mezcla salud y confiabilidad oculta el problema más peligroso: el Score que se ve bien con datos malos. FARO separa salud (¿cómo está la empresa?) de confianza (¿qué tan confiable es esta lectura?).

Regla ejecutiva. Nunca mostrar Score sin mostrar confianza cuando la confianza sea baja. Score 82 con Confidence 45 no es "buena salud"; es "no tenemos datos suficientes para opinar". Mezclar ambas en un único número es la receta clásica para tomar decisiones sobre humo.

Métrica 1 · Salud

FARO Score

Mide qué tan sana está la empresa según resultado de KPIs, tensiones activas, ejecución de acciones, evidencia y gobierno. Escala 0-100. Penaliza tensiones críticas, acciones vencidas, evidencia faltante y bloqueos. Recupera con cierre validado por evidencia aprobada.

score = clamp(kpi + tension + execution + evidence + governance, 0, 100)
Métrica 2 · Confianza

Score Confidence

Mide qué tan confiable es la lectura según completitud de datos, actualidad, consistencia, evidencia y cobertura de módulos. Escala 0-100. Bajo 50 muestra alerta de lectura incompleta. Bajo 30 no se debe usar para decisiones sin revisión.

confidence = completeness·0.30 + freshness·0.20 + consistency·0.20 + evidence·0.15 + coverage·0.15
Score Confidence Lectura ejecutiva
8295Empresa bien y lectura confiable. Decisiones sostenibles.
8245Parece bien, pero faltan datos. No tomar decisiones sin revisar.
6190Empresa en riesgo y lectura confiable. Intervenir con foco.
6135Hay riesgo, pero datos incompletos. Mejorar carga antes de actuar.
6682Empresa Demo Cuyo S.A. hoy: zona de riesgo con lectura confiable.
03 · Escala 0-100 y 6 estados

Seis bandas operativas con lectura ejecutiva fija

El estado se deriva del Score con una función pura. No depende del contexto ni del observador: scoreStatus(score) devuelve siempre el mismo valor para el mismo número.

90-100
Excelente
excellent

Sistema sano, control alto, ejecución sólida. Sin tensiones críticas activas.

80-89
Bueno
good

Empresa saludable con focos menores. Hay tensiones bajas o medias en curso.

70-79
Atención
attention

Hay tensiones relevantes, pero manejables. Dirección debe seguir de cerca.

60-69
Riesgo
warning

Hay deterioro ejecutivo y acciones críticas. Empresa Demo Cuyo S.A. está acá.

40-59
Crítico
critical

Riesgo fuerte de margen, caja, operación o gobierno. Intervención inmediata.

0-39
Emergencia
emergency

Dirección debe intervenir de inmediato. Múltiples tensiones críticas sin cierre.

Helper TypeScript canónico

▸ TS · src/score/score.helpers.ts
export function scoreStatus(score: number): ScoreStatus {
  if (score >= 90) return "excellent";
  if (score >= 80) return "good";
  if (score >= 70) return "attention";
  if (score >= 60) return "warning";
  if (score >= 40) return "critical";
  return "emergency";
}

export function clamp(value: number, min = 0, max = 100): number {
  return Math.min(max, Math.max(min, value));
}

Helper SQL inmutable

▸ SQL · V068__create_score_helpers.sql
CREATE OR REPLACE FUNCTION faro.score_status(p_score numeric)
RETURNS text AS $$
BEGIN
  IF p_score >= 90 THEN RETURN 'excellent';
  ELSIF p_score >= 80 THEN RETURN 'good';
  ELSIF p_score >= 70 THEN RETURN 'attention';
  ELSIF p_score >= 60 THEN RETURN 'warning';
  ELSIF p_score >= 40 THEN RETURN 'critical';
  ELSE RETURN 'emergency';
  END IF;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
04 · Las 5 dimensiones canónicas

KPI 30 + Tension 25 + Execution 20 + Evidence 15 + Governance 10 = 100

El Score MVP usa cinco dimensiones con peso máximo fijo. Esto no es promedio simple: cada dimensión se calcula con lógica propia, penalizaciones, límites y confianza.

Dimensión 1

Salud KPI

kpi_health
30 pts

Mide si los indicadores clave están dentro de rango saludable: ventas, margen, descuento, cobranza, stock crítico, rotación, mora, conversión, productividad. Cada KPI se normaliza a una salud entre 0 y 1 según su tipo (higher/lower/range/binary) y se pondera por peso y confianza del dato.

Cómo se calcula
30 × weighted_average(kpi_health_ratio × kpi_weight × data_confidence)
Inputs
KPIs reales vs target, tipo de KPI, peso por industria, confianza del dato (data_confidence)
Vista
v_score_kpi_inputs (consume faro.kpi_values + faro.kpi_definitions)
Peso máximo30 / 100 = 30%
Dimensión 2

Tensiones activas

tension_health
25 pts

Parte de 25 puntos y resta penalizaciones por tensiones activas. Una tensión crítica activa baja Score aunque los KPIs aislados parezcan buenos: ventas +18% con margen 28→21% y descuentos 6→12% es crecimiento no rentable, no salud.

Cómo se calcula
max(0, 25 − total_tension_penalty)
Penalty
severity × priority × confidence × status_multiplier × recurrence × impact
Inputs
Tensiones activas (no closed/rejected), severidad, prioridad, estado, reincidencia, score_impact
Vista
v_score_tension_inputs (consume faro.tensions + faro.tension_definitions)
Peso máximo25 / 100 = 25%
Dimensión 3

Ejecución de acciones

execution_health
20 pts

No alcanza con detectar tensiones: si no se ejecuta, FARO penaliza. Acciones vencidas, bloqueadas, sin responsable o sin criterio de cierre restan; acciones cerradas con evidencia aprobada recuperan. La recuperación está topeada al peso máximo: no se puede inflar Score creando acciones fáciles.

Cómo se calcula
max(0, min(20, 20 − execution_penalty + execution_recovery))
Penalty
Critical vencida -4, high vencida -2, blocked critical -3, sin responsable -3, escalada L3/L4 -2, reabierta -2
Recovery
Cerrada con evidencia high +3, crítica cerrada antes de SLA +4, aprobada +2, iniciada +0.5
Vista
v_score_action_inputs (consume faro.actions + faro.action_definitions)
Peso máximo20 / 100 = 20%
Dimensión 4

Evidencia y cierre

evidence_health
15 pts

Mide calidad de cierre. Una acción sin evidencia no debe recuperar Score completo. La evidencia se pondera por trust level (critical 1.0 → low 0.4), y se penalizan rechazos y solicitudes de más info. FARO no premia relatos; premia evidencia documentada.

Cómo se calcula
15 × evidence_completion_ratio × evidence_trust_ratio
Penalty
Evidencia critical faltante -3, high faltante -2, rechazada -2, needs_more_info -1, cierre manual EVD-009 -2 confianza
Inputs
Evidencias requeridas, trust_level, status (approved/rejected/needs_more_info/missing)
Vista
v_score_evidence_inputs (consume faro.evidence + faro.evidence_definitions)
Peso máximo15 / 100 = 15%
Dimensión 5

Gobierno y riesgo

governance_health
10 pts

Mide si la empresa gobierna su propio sistema de ejecución: escalamientos abiertos, bloqueos, tensiones críticas sin responsable, aprobadores sin respuesta, data confidence baja, reportes no generados, reglas críticas desactivadas. Es la dimensión que delata empresas que tienen sistema pero no lo operan.

Cómo se calcula
max(0, 10 − governance_penalty)
Penalty
Escalamiento L4 -3, L3 -2, L2 -1, tensión crítica sin responsable -3, sin acción -3, aprobador no responde -2, data confidence < 60 -2, reglas críticas off -3
Inputs
Tabla faro.escalations, faro.tensions, faro.actions, faro.rule_definitions
Peso máximo10 / 100 = 10%

Tabla resumen de pesos

Dimensión Código Peso máximo Tipo de cálculo Fuente principal
Salud KPIkpi_health30 ptsSuma ponderada normalizadakpi_values + kpi_definitions
Tensiones activastension_health25 ptsResta penalizaciones desde 25tensions + tension_definitions
Ejecuciónexecution_health20 ptsPenalty + recovery con cap 20actions + action_definitions
Evidenciaevidence_health15 ptsCompletion × trust ratioevidence + evidence_definitions
Gobiernogovernance_health10 ptsResta penalizaciones desde 10escalations + rule_definitions
Total FARO Score100 ptsclamp(sum, 0, 100)score_snapshots
05 · Fórmula general MVP

Versionada en score_model_versions, no hardcodeada

Toda la fórmula vive en una tabla versionada. Cambiar pesos no es ajustar código: es publicar una nueva versión del modelo y guardar el snapshot apuntando a la versión usada. Histórico intacto, auditoría posible.

score_raw =
  kpi_health_points ∈ [0, 30]
+ tension_health_points ∈ [0, 25]
+ execution_health_points ∈ [0, 20]
+ evidence_health_points ∈ [0, 15]
+ governance_health_points ∈ [0, 10]

score_final = clamp(score_raw, 0, 100)

Detalle por componente

▸ Fórmula matemática
KPI Health        = 30 × weighted_average(kpi_health_ratio × kpi_weight × data_confidence)
Tension Health    = max(0, 25 − Σ tension_penalty)
Execution Health  = max(0, min(20, 20 − execution_penalty + execution_recovery))
Evidence Health   = 15 × evidence_completion_ratio × evidence_trust_ratio
Governance Health = max(0, 10 − governance_penalty)

FARO Score = clamp(KPI + Tension + Execution + Evidence + Governance, 0, 100)

Modelo persistido en SQL

▸ SQL · V063__create_score_model_versions.sql
CREATE TABLE IF NOT EXISTS faro.score_model_versions (
  score_model_version_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  model_code text NOT NULL,
  version integer NOT NULL,
  name text NOT NULL,
  description text NOT NULL,
  industry_code text NULL,
  business_model_code text NULL,
  is_default boolean NOT NULL DEFAULT false,
  is_active boolean NOT NULL DEFAULT true,
  config jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  UNIQUE(model_code, version)
);
▸ SQL · V064__seed_score_model_mvp.sql · config jsonb
{
  "dimensions": {
    "kpi_health":        { "max_points": 30 },
    "tension_health":    { "max_points": 25 },
    "execution_health":  { "max_points": 20 },
    "evidence_health":   { "max_points": 15 },
    "governance_health": { "max_points": 10 }
  },
  "severity_weights": { "low": 1, "medium": 2, "high": 4, "critical": 6 },
  "status_multipliers": {
    "new": 1, "assigned": 0.95, "in_analysis": 0.90,
    "in_execution": 0.75, "in_verification": 0.45,
    "blocked": 1.10, "escalated": 1.20,
    "closed": 0, "reopened": 1.30, "rejected": 0
  },
  "evidence_trust_weights": { "low": 0.40, "medium": 0.65, "high": 0.85, "critical": 1.00 }
}

Cambiar pesos es gobierno, no código. Aumentar version, dejar la anterior is_active = false, dejar is_default = true la nueva. Los snapshots históricos guardan score_model_version_id, por lo que el Score de hace 6 meses sigue siendo el mismo aunque el modelo activo haya cambiado.

06 · Score Confidence

Métrica independiente 0-100 sobre calidad del dato

El Confidence se calcula desde cinco componentes con pesos fijos. Bajo 70 muestra advertencia; bajo 50 muestra alerta de lectura incompleta; bajo 30 no se debe usar para decisiones sin revisión.

score_confidence =
  data_completeness × 0.30
+ data_freshness × 0.20
+ data_consistency × 0.20
+ evidence_confidence × 0.15
+ module_coverage × 0.15
30%
Completitud
data_completeness
20%
Actualidad
data_freshness
20%
Consistencia
data_consistency
15%
Evidencia
evidence_confidence
15%
Cobertura módulos
module_coverage

Clasificación operativa

Rango Estado Visualización Acción
85-100highScore normal sin advertenciaUsar para decisiones ejecutivas
70-84goodScore normal sin advertenciaUsar para decisiones operativas
50-69mediumScore con advertencia visibleConfirmar fuentes antes de decidir
30-49lowScore con alerta "lectura incompleta"No usar sin revisión manual
0-29criticalScore bloqueado para decisionesCargar datos faltantes antes de leer

Cálculo TypeScript

▸ TS · src/score/score.confidence.ts
export function calculateScoreConfidence(input: {
  kpiConfidence: number;
  tensionConfidence: number;
  evidenceConfidence: number;
  governanceConfidence: number;
  moduleCoverage: number;
}): number {
  // Completeness: promedio de confianzas dimensionales
  const completeness = (
    input.kpiConfidence +
    input.tensionConfidence +
    input.governanceConfidence
  ) / 3;

  // Freshness: derivado de data_quality_metrics
  const freshness = clamp(input.kpiConfidence, 0, 100);

  // Consistency: cruces canónicos válidos
  const consistency = clamp(input.governanceConfidence, 0, 100);

  const raw =
      completeness         * 0.30
    + freshness            * 0.20
    + consistency          * 0.20
    + input.evidenceConfidence * 0.15
    + input.moduleCoverage * 0.15;

  return round2(clamp(raw, 0, 100));
}

export function confidenceStatus(confidence: number): ConfidenceStatus {
  if (confidence >= 85) return "high";
  if (confidence >= 70) return "good";
  if (confidence >= 50) return "medium";
  if (confidence >= 30) return "low";
  return "critical";
}
07 · KPI Health · TS

Normalización por tipo: higher / lower / range / binary

Cada KPI se normaliza a una salud entre 0 y 1 según su kpi_direction. Luego se pondera por peso del KPI y confianza del dato, y se suma a 30 puntos máximo.

Helper de normalización por tipo

▸ TS · src/score/score.kpis.ts
export type KpiDirection =
  | "higher_is_better"
  | "lower_is_better"
  | "range_is_better"
  | "binary";

export function normalizeKpiHealth(params: {
  actual: number;
  target?: number | null;
  min?: number | null;
  max?: number | null;
  direction: KpiDirection;
}): number {
  const { actual, target, min, max, direction } = params;

  if (direction === "binary") {
    return actual ? 1 : 0;
  }

  if (direction === "higher_is_better") {
    if (!target || target <= 0) return 0.5;
    return clamp(actual / target, 0, 1);
  }

  if (direction === "lower_is_better") {
    if (!target || target <= 0) return 0.5;
    if (actual <= target) return 1;
    return clamp(target / actual, 0, 1);
  }

  if (direction === "range_is_better") {
    if (min == null || max == null) return 0.5;
    if (actual >= min && actual <= max) return 1;
    if (actual < min) return clamp(actual / min, 0, 1);
    return clamp(max / actual, 0, 1);
  }

  return 0.5;
}

Cálculo agregado de la dimensión

▸ TS · src/score/score.kpis.ts
export function calculateKpiHealthPoints(rows: KpiInputRow[]): {
  points: number;
  confidence: number;
  drivers: ScoreDriver[];
} {
  const maxPoints = 30;

  if (rows.length === 0) {
    return { points: 0, confidence: 0, drivers: [] };
  }

  let weightedSum = 0;
  let weightTotal = 0;
  let confidenceTotal = 0;
  const drivers: ScoreDriver[] = [];

  for (const row of rows) {
    const health = normalizeKpiHealth({
      actual: row.actual,
      target: row.target,
      min: row.min_value,
      max: row.max_value,
      direction: row.direction
    });

    const weight = row.kpi_weight ?? 1;
    const dataConf = clamp(row.data_confidence / 100, 0, 1);

    weightedSum += health * weight * dataConf;
    weightTotal += weight;
    confidenceTotal += row.data_confidence;

    if (health < 0.7) {
      drivers.push({
        driver_type: "kpi",
        driver_code: row.kpi_code,
        driver_title: row.kpi_name,
        impact_points: round2(-(1 - health) * weight * 3),
        impact_direction: "negative",
        explanation: `${row.kpi_code} está en ${Math.round(health * 100)}% de su salud objetivo.`
      });
    }
  }

  const ratio = weightTotal > 0 ? weightedSum / weightTotal : 0;
  const points = round2(clamp(maxPoints * ratio, 0, maxPoints));
  const confidence = round2(confidenceTotal / rows.length);

  return { points, confidence, drivers };
}
08 · Tension / Execution / Evidence / Governance

Las otras cuatro dimensiones · cálculo TypeScript

Cada dimensión vive en su propio módulo TS. El motor principal (calculateFaroScore) las orquesta y agrega componentes y drivers en un único snapshot.

Tension Health

▸ TS · src/score/score.tensions.ts
export function calculateTensionPenalty(input: {
  severity: "low" | "medium" | "high" | "critical";
  priorityScore: number;
  confidenceScore: number;
  status: string;
  recurrenceCount?: number;
  scoreImpact?: number | null;
}): number {
  const severityWeights = { low: 1, medium: 2, high: 4, critical: 6 };

  const statusMultipliers: Record<string, number> = {
    new: 1, assigned: 0.95, in_analysis: 0.9,
    in_execution: 0.75, in_verification: 0.45,
    blocked: 1.1, escalated: 1.2,
    closed: 0, reopened: 1.3, rejected: 0
  };

  const recurrence = input.recurrenceCount ?? 0;
  const recurrenceMultiplier =
    recurrence >= 2 ? 1.4 : recurrence === 1 ? 1.2 : 1;

  const impactMultiplier = input.scoreImpact
    ? clamp(Math.abs(input.scoreImpact) / 8, 0.75, 1.5)
    : 1;

  const penalty =
    severityWeights[input.severity] *
    clamp(input.priorityScore / 100, 0, 1) *
    clamp(input.confidenceScore / 100, 0, 1) *
    (statusMultipliers[input.status] ?? 1) *
    recurrenceMultiplier *
    impactMultiplier;

  return round2(penalty);
}

export function aggregateTensionHealth(rows: TensionRow[]): {
  points: number;
  penalty: number;
  drivers: ScoreDriver[];
} {
  const maxPoints = 25;
  let totalPenalty = 0;
  const drivers: ScoreDriver[] = [];

  for (const row of rows) {
    const penalty = calculateTensionPenalty({
      severity: row.severity,
      priorityScore: row.priority_score,
      confidenceScore: row.confidence_score,
      status: row.status,
      recurrenceCount: row.recurrence_count,
      scoreImpact: row.score_impact
    });
    totalPenalty += penalty;
    if (penalty > 0) drivers.push(buildTensionDriver(row, penalty));
  }

  return {
    points: round2(clamp(maxPoints - totalPenalty, 0, maxPoints)),
    penalty: round2(totalPenalty),
    drivers
  };
}

Execution Health

▸ TS · src/score/score.execution.ts
export function aggregateExecutionHealth(rows: ActionRow[]): {
  points: number;
  penalty: number;
  recovery: number;
  drivers: ScoreDriver[];
} {
  const maxPoints = 20;
  let penalty = 0;
  let recovery = 0;
  const drivers: ScoreDriver[] = [];

  for (const row of rows) {
    if (row.is_overdue) {
      const p = Number(row.overdue_penalty_weight ?? 1);
      penalty += p;
      drivers.push(buildOverdueDriver(row, p));
    }

    if (row.blocked_penalty > 0) {
      penalty += row.blocked_penalty;
      drivers.push(buildBlockedDriver(row));
    }

    if (row.missing_responsible_penalty > 0) {
      penalty += row.missing_responsible_penalty;
    }

    if (row.status === "closed") {
      const r = Math.min(row.expected_score_recovery_max, 3);
      recovery += r;
      drivers.push(buildRecoveryDriver(row, r));
    }
  }

  return {
    points: round2(clamp(maxPoints - penalty + recovery, 0, maxPoints)),
    penalty: round2(penalty),
    recovery: round2(recovery),
    drivers
  };
}

Evidence Health

▸ TS · src/score/score.evidence.ts
export function calculateEvidenceHealth(input: {
  requiredCount: number;
  approvedCount: number;
  trustWeights: number[];
  rejectedCount: number;
  needsMoreInfoCount: number;
}) {
  const maxPoints = 15;

  if (input.requiredCount === 0) {
    return { points: maxPoints, penalty: 0, recovery: 0, confidence: 100 };
  }

  const completionRatio = clamp(input.approvedCount / input.requiredCount, 0, 1);

  const averageTrust =
    input.trustWeights.length > 0
      ? input.trustWeights.reduce((s, x) => s + x, 0) / input.trustWeights.length
      : 0.5;

  const basePoints = maxPoints * completionRatio * averageTrust;
  const rejectionPenalty = input.rejectedCount * 2;
  const needsMoreInfoPenalty = input.needsMoreInfoCount * 1;

  const points = clamp(basePoints - rejectionPenalty - needsMoreInfoPenalty, 0, maxPoints);

  return {
    points: round2(points),
    penalty: round2(maxPoints - points),
    recovery: round2(points),
    confidence: round2(averageTrust * 100)
  };
}

Governance Health

▸ TS · src/score/score.governance.ts
export function aggregateGovernanceHealth(input: {
  escalationsByLevel: { L2: number; L3: number; L4: number };
  criticalTensionsWithoutOwner: number;
  criticalTensionsWithoutAction: number;
  approverSlaBreaches: number;
  dataConfidenceBelowThreshold: boolean;
  weeklyReportMissing: boolean;
  criticalRulesDisabled: number;
}): { points: number; penalty: number; drivers: ScoreDriver[] } {
  const maxPoints = 10;
  let penalty = 0;
  const drivers: ScoreDriver[] = [];

  penalty += input.escalationsByLevel.L4 * 3;
  penalty += input.escalationsByLevel.L3 * 2;
  penalty += input.escalationsByLevel.L2 * 1;
  penalty += input.criticalTensionsWithoutOwner * 3;
  penalty += input.criticalTensionsWithoutAction * 3;
  penalty += input.approverSlaBreaches * 2;
  if (input.dataConfidenceBelowThreshold) penalty += 2;
  if (input.weeklyReportMissing) penalty += 1;
  penalty += input.criticalRulesDisabled * 3;

  return {
    points: round2(clamp(maxPoints - penalty, 0, maxPoints)),
    penalty: round2(penalty),
    drivers
  };
}
09 · Snapshots históricos

faro.score_snapshots · versionado, auditable, completo

Cada cálculo de Score produce un snapshot por período (start/end) con UNIQUE por dimensión. Conserva valor, valor anterior, delta, status, confidence, componentes desagregados, drivers principales y oportunidades de recuperación. Es la fuente única que consumen reporte semanal, alertas y dashboard.

DDL canónico

▸ SQL · V065__create_score_snapshots.sql
CREATE TABLE IF NOT EXISTS faro.score_snapshots (
  score_snapshot_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  company_id uuid NOT NULL,
  score_model_version_id uuid NULL,

  dimension_type text NOT NULL CHECK (
    dimension_type IN ('company','area','branch','module',
                       'responsible','tension','action')
  ),
  dimension_id text NULL,
  dimension_code text NULL,
  dimension_name text NULL,

  period_start date NOT NULL,
  period_end date NOT NULL,

  score_value numeric(9,2) NOT NULL CHECK (score_value >= 0 AND score_value <= 100),
  previous_score_value numeric(9,2) NULL,
  score_delta numeric(9,2) NULL,

  score_status text NOT NULL CHECK (
    score_status IN ('excellent','good','attention',
                     'warning','critical','emergency')
  ),

  score_confidence numeric(9,2) NOT NULL DEFAULT 100
    CHECK (score_confidence >= 0 AND score_confidence <= 100),
  confidence_status text NOT NULL DEFAULT 'high'
    CHECK (confidence_status IN ('high','good','medium','low','critical')),

  components jsonb NOT NULL DEFAULT '{}'::jsonb,
  drivers jsonb NOT NULL DEFAULT '[]'::jsonb,
  recovery_opportunities jsonb NOT NULL DEFAULT '[]'::jsonb,
  explanation jsonb NOT NULL DEFAULT '{}'::jsonb,

  calculated_by text NOT NULL DEFAULT 'score_engine',
  calculated_at timestamptz NOT NULL DEFAULT now(),
  created_at timestamptz NOT NULL DEFAULT now(),

  UNIQUE(company_id, dimension_type, dimension_id, period_start, period_end)
);

CREATE INDEX IF NOT EXISTS idx_score_snapshots_company_period
  ON faro.score_snapshots(company_id, period_start DESC, period_end DESC);
CREATE INDEX IF NOT EXISTS idx_score_snapshots_status
  ON faro.score_snapshots(company_id, score_status, period_end DESC);
CREATE INDEX IF NOT EXISTS idx_score_snapshots_drivers
  ON faro.score_snapshots USING gin(drivers);

Cómo se versiona

  • Por período: cada cálculo escribe un snapshot con period_start y period_end. La UNIQUE garantiza un único snapshot por dimensión y período. Recalcular el mismo período hace ON CONFLICT DO UPDATE.
  • Por modelo: cada snapshot guarda score_model_version_id. Si cambian los pesos del modelo, los snapshots viejos siguen apuntando a la versión que los calculó. Auditoría completa.
  • Por dimensión: empresa, área, sucursal, responsable, módulo, tensión, acción. El MVP obliga company/area/responsible; el resto queda preparado.
  • Por delta: previous_score_value y score_delta se calculan contra el último snapshot anterior de la misma dimensión. Permite ver evolución sin recalcular.

Snapshots históricos · Empresa Demo Cuyo S.A.

Período Score Δ Status Confidence Driver principal
2026-05-04 → 1078attention84Estable, sin tensiones críticas activas
2026-05-11 → 1776-2attention83Margen empieza a ceder en línea retail
2026-05-18 → 2474-2attention82TNS-001 dispara: crecimiento no rentable
2026-05-25 → 31 (actual)66-8warning82TNS-001 + TNS-004 + acciones críticas vencidas

Persistencia desde el motor

▸ TS · src/score/saveFaroScoreSnapshot.ts
export async function saveFaroScoreSnapshot(params: {
  client: pg.PoolClient;
  result: FaroScoreResult;
  scoreModelVersionId?: string | null;
}): Promise<string> {
  const r = params.result;

  const snapshot = await params.client.query(
    `
    INSERT INTO faro.score_snapshots (
      company_id, score_model_version_id,
      dimension_type, dimension_id, dimension_code, dimension_name,
      period_start, period_end,
      score_value, previous_score_value, score_delta,
      score_status, score_confidence, confidence_status,
      components, drivers, recovery_opportunities, explanation
    )
    VALUES (
      $1, $2, $3, $4, $5, $6, $7, $8,
      $9, $10, $11, $12, $13, $14,
      $15::jsonb, $16::jsonb, $17::jsonb, $18::jsonb
    )
    ON CONFLICT (company_id, dimension_type, dimension_id, period_start, period_end)
    DO UPDATE SET
      score_value = EXCLUDED.score_value,
      score_delta = EXCLUDED.score_delta,
      score_status = EXCLUDED.score_status,
      score_confidence = EXCLUDED.score_confidence,
      components = EXCLUDED.components,
      drivers = EXCLUDED.drivers,
      recovery_opportunities = EXCLUDED.recovery_opportunities,
      explanation = EXCLUDED.explanation,
      calculated_at = now()
    RETURNING score_snapshot_id
    `,
    [
      r.company_id, params.scoreModelVersionId ?? null,
      r.dimension_type, r.dimension_id, r.dimension_code, r.dimension_name,
      r.period_start, r.period_end,
      r.score_value, r.previous_score_value, r.score_delta,
      r.score_status, r.score_confidence, r.confidence_status,
      JSON.stringify(r.components),
      JSON.stringify(r.drivers),
      JSON.stringify(r.recovery_opportunities),
      JSON.stringify(r.explanation)
    ]
  );

  return snapshot.rows[0].score_snapshot_id;
}
10 · Explicación ejecutiva

Top drivers de caída/mejora con código, título e impacto

El motor genera explicación estructurada sin IA libre: identifica los drivers principales (qué tensiones, acciones, evidencias y bloqueos movieron más el Score), los ordena por valor absoluto de impacto y los entrega como dataset listo para reporte y UI.

FARO calcula. FARO gobierna. La IA redacta y explica. La IA (FARO-AI-001 · decisión D5) puede generar narrativa ejecutiva con slots controlados sobre estos drivers, pero no inventa datos, no cambia reglas y no cierra acciones. La fórmula sigue siendo determinística; la IA solo cambia la prosa.

Builder de drivers principales

▸ TS · src/score/buildScoreExplanation.ts
export function buildScoreExplanation(params: {
  scoreValue: number;
  previous: number | null;
  drivers: ScoreDriver[];
}): ScoreExplanation {
  const sorted = [...params.drivers].sort(
    (a, b) => Math.abs(b.impact_points) - Math.abs(a.impact_points)
  );

  const mainNegative = sorted
    .filter((d) => d.impact_direction === "negative")
    .slice(0, 4);

  const mainPositive = sorted
    .filter((d) => d.impact_direction === "positive")
    .slice(0, 4);

  const delta = params.previous == null
    ? null
    : round2(params.scoreValue - params.previous);

  const headline = buildScoreHeadline({
    scoreValue: params.scoreValue,
    delta,
    topNegativeDriver: mainNegative[0]
  });

  const summary = buildScoreSummary({
    scoreValue: params.scoreValue,
    delta,
    mainNegative,
    mainPositive
  });

  return {
    headline,
    summary,
    main_negative_drivers: mainNegative,
    main_positive_drivers: mainPositive
  };
}

export function buildScoreHeadline(params: {
  scoreValue: number;
  delta: number | null;
  topNegativeDriver?: ScoreDriver;
}): string {
  if (params.delta !== null && params.delta <= -8) {
    return "El FARO Score cayó por deterioro ejecutivo relevante.";
  }
  if (params.scoreValue < 60) {
    return "La empresa se encuentra en zona crítica de dirección.";
  }
  if (params.topNegativeDriver) {
    return `El principal factor de riesgo es ${params.topNegativeDriver.driver_title}.`;
  }
  if (params.delta !== null && params.delta > 0) {
    return "El FARO Score mejora por avance en ejecución validada.";
  }
  return "El FARO Score se mantiene estable con focos pendientes.";
}

Drivers Empresa Demo Cuyo · período actual 74→66

Tipo Código Título Impacto Explicación
tensionTNS-001Crecimiento no rentable-8.5Severidad high, status in_analysis, confidence 90, prioridad 88
tensionTNS-004Venta sin conversión a caja-6.0Severidad high, status assigned, cobranza 32→43 días
actionACT-COM-001Revisar política de descuento-3.0Acción crítica vencida hace 4 días, sin evidencia cargada
evidenceEVD-012Evidencia crítica faltante-2.04 evidencias críticas pendientes en acciones cerradas sin trust
governanceESC-L3Escalamiento L3 abierto-2.02 escalamientos L3 sin respuesta del aprobador en SLA
recoveryACT-FIN-002Plan de cobranza acelerada+3.0Acción en revisión con evidencia high cargada y aprobada parcial
recoveryACT-STK-001Reposición planificada SKU críticos+1.5Acción iniciada con calendario definido y responsable asignado
11 · Recuperación potencial

expected_score_recovery_max agregado por acciones en curso

FARO no se limita a explicar la caída. Estima cuánto puede recuperar el Score si se cierran con evidencia aprobada las acciones en curso. Es la métrica que convierte el Score de diagnóstico a operativo.

Regla anti-gaming. La recuperación no puede superar la penalización original de la tensión/acción que la motivó. Crear acciones fáciles para subir Score no funciona: la recovery está topeada por expected_score_recovery_max definido en el catálogo de acciones, y solo se libera cuando se cierra con evidencia high o critical aprobada.

Cálculo agregado de recuperación

▸ TS · src/score/score.execution.ts
export function calculatePotentialRecovery(rows: ActionRow[]): {
  total_max: number;
  total_min: number;
  opportunities: ScoreDriver[];
} {
  const opportunities: ScoreDriver[] = [];
  let totalMax = 0;
  let totalMin = 0;

  for (const row of rows) {
    if (row.status === "closed" || row.status === "cancelled") continue;

    const recoveryMax = Number(row.expected_score_recovery_max ?? 0);
    const recoveryMin = Number(row.expected_score_recovery_min ?? 0);

    if (recoveryMax <= 0) continue;

    // Multiplicador por estado: cuanto más avanzada, más probable recuperar
    const readiness = row.status === "in_verification" ? 0.85
                    : row.status === "in_execution"   ? 0.65
                    : row.status === "in_analysis"    ? 0.40
                    : row.status === "assigned"       ? 0.25
                    : 0.10;

    const expectedMax = round2(recoveryMax * readiness);
    const expectedMin = round2(recoveryMin * readiness);

    totalMax += expectedMax;
    totalMin += expectedMin;

    opportunities.push({
      driver_type: "recovery",
      driver_code: row.action_code,
      driver_title: row.title,
      impact_points: expectedMax,
      impact_direction: "positive",
      explanation: `${row.action_code} puede recuperar +${expectedMax} pts si cierra con evidencia high/critical aprobada.`,
      payload: {
        action_id: row.action_id,
        status: row.status,
        expected_min: expectedMin,
        expected_max: expectedMax
      }
    });
  }

  return {
    total_max: round2(totalMax),
    total_min: round2(totalMin),
    opportunities: opportunities.sort(
      (a, b) => b.impact_points - a.impact_points
    )
  };
}

Recuperación potencial · Empresa Demo Cuyo · +16 pts

ACT-COM-001 +5.0 pts
Revisar política de descuento

Cierra TNS-001 (Crecimiento no rentable). Requiere evidencia EVD-007 + EVD-012 aprobadas.

ACT-FIN-002 +4.5 pts
Plan de cobranza acelerada

Cierra TNS-004 (Venta sin conversión a caja). Status in_verification con evidencia parcial.

ACT-COM-002 +3.0 pts
Auditar mix vendedor + margen

Reduce penalty de TNS-001 secundario. Evidencia EVD-010 + EVD-012 críticas.

ACT-FIN-001 +2.5 pts
Llamado a clientes en mora

Reduce penalty TNS-005 (Mora crítica). Status in_execution con responsable asignado.

ACT-STK-001 +1.0 pt
Reposición planificada SKU críticos

Reduce penalty TNS-006 (Stock crítico). Evidencia EVD-005 cargada y validada.

Total recuperación potencial: +16 puntos. Score actual 66 → Score potencial 82 si se cierran las 5 acciones con evidencia high/critical aprobada antes del próximo período. La estimación pondera por readiness de cada acción (in_verification 0.85, in_execution 0.65, etc.).

12 · Tests de fórmula obligatorios

Vitest · cobertura mínima de helpers y dimensiones

La fórmula es código, y código sin tests es opinión. Estos tests son obligatorios antes de poner el motor en producción. Sin pasar acá, el modelo no se promueve a is_active = true.

Matriz mínima de tests

Categoría Test Esperado
HelpersClamp bajo 0devuelve 0
Clamp sobre 100devuelve 100
scoreStatus(90)excellent
scoreStatus(66)warning
confidenceStatus(45)low
round2(0.123)0.12
KPIhigher_is_better dentro de targethealth = 1
higher_is_better mitad de targethealth = 0.5
lower_is_better debajo del targethealth = 1
range_is_better fuera del rangohealth proporcional
Tensioncritical penaliza más que hightrue
closed no penalizapenalty = 0
blocked aumenta penaltymultiplier 1.10
Evidenceaprobada con trust highrecupera proporcional
faltante críticabloquea recuperación total
Snapshotinsert + readOK
UNIQUE por períodoconflict → update

Test ejemplo · score.tensions.test.ts

▸ TS · tests/score.tensions.test.ts
import { describe, expect, it } from "vitest";
import { calculateTensionPenalty } from "../src/score/score.tensions";

describe("FARO Score · tension penalty", () => {
  it("penalizes critical more than high", () => {
    const critical = calculateTensionPenalty({
      severity: "critical",
      priorityScore: 90,
      confidenceScore: 90,
      status: "new"
    });
    const high = calculateTensionPenalty({
      severity: "high",
      priorityScore: 90,
      confidenceScore: 90,
      status: "new"
    });
    expect(critical).toBeGreaterThan(high);
  });

  it("does not penalize closed tension", () => {
    const penalty = calculateTensionPenalty({
      severity: "critical",
      priorityScore: 100,
      confidenceScore: 100,
      status: "closed"
    });
    expect(penalty).toBe(0);
  });

  it("applies recurrence multiplier 1.4 when count >= 2", () => {
    const base = calculateTensionPenalty({
      severity: "high",
      priorityScore: 80,
      confidenceScore: 80,
      status: "new"
    });
    const recurrent = calculateTensionPenalty({
      severity: "high",
      priorityScore: 80,
      confidenceScore: 80,
      status: "new",
      recurrenceCount: 2
    });
    expect(recurrent).toBeCloseTo(base * 1.4, 1);
  });
});

Test ejemplo · score.helpers.test.ts

▸ TS · tests/score.helpers.test.ts
import { describe, expect, it } from "vitest";
import { clamp, scoreStatus, confidenceStatus } from "../src/score/score.helpers";

describe("FARO Score · helpers", () => {
  it("clamps under 0 to 0", () => {
    expect(clamp(-10, 0, 100)).toBe(0);
  });

  it("clamps over 100 to 100", () => {
    expect(clamp(150, 0, 100)).toBe(100);
  });

  it("maps score thresholds correctly", () => {
    expect(scoreStatus(95)).toBe("excellent");
    expect(scoreStatus(82)).toBe("good");
    expect(scoreStatus(74)).toBe("attention");
    expect(scoreStatus(66)).toBe("warning");
    expect(scoreStatus(50)).toBe("critical");
    expect(scoreStatus(20)).toBe("emergency");
  });

  it("maps confidence thresholds correctly", () => {
    expect(confidenceStatus(90)).toBe("high");
    expect(confidenceStatus(75)).toBe("good");
    expect(confidenceStatus(60)).toBe("medium");
    expect(confidenceStatus(40)).toBe("low");
    expect(confidenceStatus(20)).toBe("critical");
  });
});
13 · Demo · Empresa Demo Cuyo S.A. · 74 → 66

El ciclo canónico del pack con recuperación potencial +16

La demo aplica la decisión D7: Score cae de 74 a 66 (delta -8) en el período del 25 al 31 de mayo de 2026, con recuperación potencial estimada en +16 puntos si se cierran las acciones críticas con evidencia aprobada. Reemplaza el viejo ciclo 74→78 del pack inicial.

FARO Score · Empresa Demo Cuyo S.A.
Período 2026-05-25 → 2026-05-31
66warning
−8 pts vs período anterior (74)

La empresa está en zona de riesgo. Deterioro principal: crecimiento no rentable, cobranza desfasada y acciones críticas con evidencia pendiente.

Score Confidence
Lectura confiable
82good
Lectura sostenible · sin alerta
Recuperación potencial
+16 pts · 66 → 82 si se cierran críticas con evidencia

Inputs del período

Variable Valor Lectura
Ventas+18% vs período anteriorCrecimiento alto en volumen
Margen bruto28% → 21%Caída -7 puntos
Descuento promedio6% → 12%Duplicación, fuera de política
Días de cobranza32 → 43 díasDeterioro +11 días
Stock críticoAlto en 4 SKUs ARiesgo de venta perdida
Acciones críticas vencidas3Sin cierre operativo
Evidencias críticas faltantes4Cierre sin trust
Escalamientos abiertos2 L3Aprobadores en SLA
Score Confidence82Lectura sostenible

Componentes desagregados

kpi_health
Salud KPI
20/ 30
66% · margen y cobranza fuera de target
tension_health
Tensiones activas
14/ 25
56% · penalty 11 por TNS-001/004
execution_health
Ejecución
12/ 20
60% · 3 críticas vencidas, 2 bloqueadas
evidence_health
Evidencia
10/ 15
67% · 4 críticas faltantes
governance_health
Gobierno
10/ 10
100% · responsables asignados, reglas activas

Suma final

Composición
Score raw
KPI Health20
Tension Health14
Execution Health12
Evidence Health10
Governance Health10
Score raw66
Clamp y status
Score final
clamp(66, 0, 100)66
scoreStatus(66)warning
previous (period -1)74
delta-8
confidence82
FARO Score66
Recuperación potencial
Score potencial
ACT-COM-001+5.0
ACT-FIN-002+4.5
ACT-COM-002+3.0
ACT-FIN-001+2.5
ACT-STK-001+1.0
Score potencial82

Lectura ejecutiva final

FARO Score: 66 · warning · confidence 82 · delta −8. La empresa está en zona de riesgo. El deterioro principal proviene de crecimiento no rentable (TNS-001 · -8.5), venta sin conversión a caja (TNS-004 · -6.0), acciones críticas vencidas (-3.0), evidencia crítica pendiente (-2.0) y escalamiento L3 sin respuesta (-2.0). Recuperación potencial: +16 puntos si se cierran las 5 acciones críticas con evidencia high/critical aprobada antes del próximo período de cálculo.

Datos demostrativos. Empresa Demo Cuyo S.A. es una empresa simulada para uso del pack NDA. Métricas y nombres no corresponden a clientes reales. El ciclo 74→66 reemplaza el viejo 74→78 del pack inicial por aplicación de la decisión D7 del NDA: el Score que mejora sin evidencia aprobada no es defendible, el que cae con drivers explicables y recuperación potencial sí lo es.

14 · Cross-references

Dónde se cruza este motor con el resto del pack

El motor de Score no vive solo. Consume catálogos canónicos, alimenta UI y reportes, y se conecta con workflow, evaluador y IA.

Próximos pasos

  1. FARO-TPL-002 · Reporte Semanal Ejecutivo MVP. Construir reporte-semanal-mvp-spec.html que consume score_snapshots y traduce drivers, components y recovery a narrativa semanal accionable.
  2. FARO-AI-001 · Explicación Ejecutiva Controlada con IA. Construir ai-gateway-mvp.html con slots fijos que redactan headline + summary sobre drivers reales, sin inventar y sin cambiar la fórmula.
  3. FARO-JOB-001 · Score Recalculation Job. Job programado cada 30-60 minutos y bajo demanda tras eventos críticos (tensión cerrada, acción cerrada, evidencia aprobada, escalamiento generado).
  4. FARO-API-SCORE-001 · API current / recalculate / history / drivers. Endpoints REST que exponen Score actual, histórico y drivers para UI y reportes.
  5. FARO-LEARN-001 · Recalibración futura. Pesos por industria, benchmarks externos y aprendizaje supervisado para ajustar el modelo sin perder histórico.