# FARO-ENG-003 · Motor Evaluador MVP

**Código:** FARO-ENG-003
**Nombre:** Motor Evaluador MVP FARO Connect
**Versión:** v1.0
**Estado:** Especificación técnica + scaffold inicial
**Prioridad:** P1 · Crítico para que FARO funcione como sistema
**Lenguaje recomendado:** TypeScript / Node.js
**Base de datos:** PostgreSQL 15+
**Depende de:**

* FARO-SQL-001 · Migraciones Base MVP
* FARO-SQL-002 · Multiempresa, Roles y RLS
* FARO-SQL-003 · Seeds Empresa Demo
* FARO-CFG-001 · Reglas MVP en YAML
* FARO-ENG-002 · DSL Parser Reglas YAML

**Conecta con:**

* FARO-UI-001 · Bandeja de Tensiones
* FARO-UI-002 · Dashboard Responsable
* FARO-TPL-001 · Templates Email Alertas
* FARO-TPL-002 · Reporte Semanal Ejecutivo
* FARO-TEST-001 · Tests KPIs MVP
* FARO-TEST-002 · Tests Reglas MVP
* FARO-DEMO-001 · Dataset Demo Integral

---

# 1. Objetivo

El objetivo de FARO-ENG-003 es construir el motor que evalúa reglas FARO contra datos reales ya calculados.

Este motor toma:

```text
kpi_snapshots
+ rule_definitions
+ contexto empresa/período
+ confianza del dato
+ reglas YAML importadas
```

y produce:

```text
rule_evaluations
→ tensions
→ actions
→ evidence requirements
→ notifications
→ score impact payload
```

El motor evaluador es el corazón operativo del MVP.

Sin este motor, FARO tiene datos, tablas y reglas, pero no decide nada.

Con este motor, FARO puede decir:

```text
Esto está pasando.
Esta tensión se activó.
Esta es la causa.
Este es el responsable.
Esta es la acción sugerida.
Esta es la evidencia requerida.
Este es el impacto sobre el Score.
```

Eso ya no es dashboard. Eso es dirección accionable.

---

# 2. Tesis técnica

El motor evaluador debe ser:

| Principio      | Significado                                                     |
| -------------- | --------------------------------------------------------------- |
| Determinístico | La misma regla con los mismos datos debe dar el mismo resultado |
| Auditable      | Toda evaluación debe quedar registrada                          |
| Multiempresa   | Nunca debe mezclar empresas                                     |
| Seguro         | Debe respetar RLS y contexto `company_id`                       |
| Idempotente    | Correr dos veces no debe duplicar tensiones innecesariamente    |
| Explicable     | Debe registrar por qué disparó o no disparó                     |
| Configurable   | Debe leer reglas desde `faro.rule_definitions`                  |
| Extensible     | Debe permitir nuevas reglas sin cambiar código                  |
| Prudente       | Si falta dato o baja confianza, no debe inventar                |
| Accionable     | Toda tensión debe tener acción, responsable y evidencia         |

---

# 3. Qué hace y qué no hace el motor

## 3.1 Hace

| Función                    | Descripción                         |
| -------------------------- | ----------------------------------- |
| Lee reglas activas         | Desde `faro.rule_definitions`       |
| Lee KPIs                   | Desde `faro.kpi_snapshots`          |
| Valida datos requeridos    | KPIs, confianza, período, dimensión |
| Evalúa condiciones         | `all`, `any`, `none`                |
| Calcula severidad          | Default + escalations               |
| Registra evaluación        | En `faro.rule_evaluations`          |
| Crea tensión               | En `faro.tensions` si corresponde   |
| Propone acciones           | En `faro.actions`                   |
| Define evidencia requerida | En metadata o relación futura       |
| Calcula impacto Score      | Payload para Score                  |
| Emite notificaciones base  | Opcional MVP                        |
| Registra auditoría         | En `audit.audit_log`                |

## 3.2 No hace

| No hace                             | Motivo                                      |
| ----------------------------------- | ------------------------------------------- |
| No calcula KPIs base                | Eso corresponde a FARO-TEST-001 / motor KPI |
| No importa YAML                     | Eso corresponde a FARO-ENG-002              |
| No cierra acciones                  | Requiere evidencia y aprobación humana      |
| No aprueba evidencia                | Lo hace responsable/aprobador               |
| No modifica RAW                     | RAW es inmutable                            |
| No usa IA para decidir              | IA solo explica                             |
| No recalibra reglas automáticamente | Fase posterior                              |
| No hace simulaciones complejas      | Fase posterior                              |
| No reemplaza criterio directivo     | Recomienda y ordena, no gobierna solo       |

---

# 4. Flujo general del motor

```text
1. Recibir company_id, período y modo de evaluación.
2. Setear contexto RLS en PostgreSQL.
3. Cargar reglas activas.
4. Cargar kpi_snapshots requeridos.
5. Validar disponibilidad de datos.
6. Validar confianza mínima.
7. Evaluar condiciones.
8. Calcular severidad.
9. Calcular prioridad.
10. Registrar rule_evaluation.
11. Si dispara, crear o actualizar tensión.
12. Crear acciones sugeridas si no existen.
13. Registrar evidencia requerida.
14. Crear notificaciones opcionales.
15. Preparar payload de impacto Score.
16. Registrar auditoría.
17. Devolver resumen de corrida.
```

---

# 5. Arquitectura recomendada del módulo

```text
faro-evaluator/
  package.json
  tsconfig.json
  .env.example
  README.md

  src/
    index.ts
    cli.ts

    config/
      evaluator.config.ts

    types/
      rule.types.ts
      kpi.types.ts
      evaluation.types.ts
      action.types.ts

    db/
      db.ts
      ruleRepository.ts
      kpiRepository.ts
      evaluationRepository.ts
      tensionRepository.ts
      actionRepository.ts
      notificationRepository.ts
      auditRepository.ts

    engine/
      evaluator.ts
      conditionEvaluator.ts
      severityCalculator.ts
      priorityCalculator.ts
      confidenceValidator.ts
      idempotency.ts
      diagnosticsBuilder.ts

    services/
      evaluationService.ts
      tensionService.ts
      actionService.ts
      scoreImpactService.ts
      notificationService.ts

    commands/
      evaluateCommand.ts
      dryRunCommand.ts
      explainCommand.ts

    utils/
      logger.ts
      dates.ts
      errors.ts

  tests/
    evaluator.test.ts
    fixtures/
      rules.ts
      kpis.ts
```

---

# 6. package.json

```json
{
  "name": "@faro/evaluator",
  "version": "1.0.0",
  "description": "FARO Connect MVP Evaluation Engine",
  "type": "module",
  "scripts": {
    "dev": "tsx src/cli.ts",
    "evaluate": "tsx src/cli.ts evaluate",
    "dry-run": "tsx src/cli.ts dry-run",
    "explain": "tsx src/cli.ts explain",
    "typecheck": "tsc --noEmit",
    "test": "vitest run"
  },
  "dependencies": {
    "commander": "^12.1.0",
    "dotenv": "^16.4.5",
    "pg": "^8.13.1",
    "zod": "^3.23.8"
  },
  "devDependencies": {
    "@types/node": "^22.10.0",
    "@types/pg": "^8.11.10",
    "tsx": "^4.19.2",
    "typescript": "^5.7.2",
    "vitest": "^2.1.8"
  }
}
```

---

# 7. tsconfig.json

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "outDir": "dist",
    "rootDir": ".",
    "types": ["node"]
  },
  "include": ["src/**/*.ts", "tests/**/*.ts"]
}
```

---

# 8. .env.example

```env
DATABASE_URL=postgresql://faro_app:password@localhost:5432/faro

FARO_DEFAULT_COMPANY_ID=10000000-0000-0000-0000-000000000001
FARO_DEFAULT_USER_ID=12000000-0000-0000-0000-000000000001
FARO_DEFAULT_ROLE_CODES=integration_service,faro_owner,company_admin

FARO_EVALUATION_MODE=normal
FARO_CREATE_ACTIONS=true
FARO_CREATE_NOTIFICATIONS=false
FARO_STRICT_CONFIDENCE=true
```

---

# 9. Conceptos base del motor

## 9.1 Rule Definition

Viene de:

```text
faro.rule_definitions
```

Contiene:

```text
rule_code
name
description
rule_body
severity_default
status
version
company_id
```

## 9.2 KPI Snapshot

Viene de:

```text
faro.kpi_snapshots
```

Contiene:

```text
kpi_code
value
reference_value
delta_value
delta_pct
status
confidence_score
period_start
period_end
dimension_type
dimension_id
```

## 9.3 Rule Evaluation

Se guarda en:

```text
faro.rule_evaluations
```

Contiene:

```text
rule_id
result
severity
confidence_score
input_payload
output_payload
status
error_message
```

## 9.4 Tension

Se crea en:

```text
faro.tensions
```

Contiene:

```text
tension_code
title
description
severity
priority_score
confidence_score
responsible_user_id
status
score_impact
payload
```

## 9.5 Action

Se crea en:

```text
faro.actions
```

Contiene:

```text
title
description
responsible_user_id
approver_user_id
status
priority
due_date
evidence_required
closure_criteria
```

---

# 10. Tipos principales

## `src/types/rule.types.ts`

```ts
export type Severity = "low" | "medium" | "high" | "critical";

export type RuleOperator =
  | ">"
  | ">="
  | "<"
  | "<="
  | "=="
  | "!="
  | "between"
  | "in"
  | "not_in"
  | "exists"
  | "missing"
  | "changed_by_pct"
  | "older_than_days";

export type Condition = {
  kpi: string;
  metric: string;
  operator: RuleOperator;
  value?: number | string | boolean | Array<number | string>;
};

export type ConditionGroup = {
  all?: Condition[];
  any?: Condition[];
  none?: Condition[];
};

export type SeverityEscalation = {
  when: ConditionGroup;
  set: Severity;
};

export type RuleBody = {
  rule_code: string;
  tension_code: string;
  version: number;
  status: string;
  name: string;
  description: string;
  scope: {
    company_types: string[];
    modules: string[];
    frequency: string;
    evaluation_window: string;
    comparison_window?: string;
    dimension?: string;
  };
  data_requirements: {
    required_kpis: string[];
    minimum_confidence_score: number;
    missing_data_policy: string;
    stale_data_policy: string;
  };
  conditions: ConditionGroup;
  severity: {
    default: Severity;
    escalation?: SeverityEscalation[];
  };
  output: {
    create_tension: boolean;
    title: string;
    diagnosis_template: string;
    recommended_actions: string[];
    assign_to_role: string;
    approver_role?: string;
    evidence_required: string[];
    default_sla_days: number;
    score_impact: {
      base: number;
      max?: number;
    };
  };
};

export type RuleDefinition = {
  ruleId: string;
  companyId: string | null;
  ruleCode: string;
  name: string;
  description: string;
  ruleType: string;
  ruleFormat: string;
  ruleBody: RuleBody;
  severityDefault: Severity;
  isMvp: boolean;
  status: string;
  version: number;
};
```

---

## `src/types/kpi.types.ts`

```ts
export type KpiSnapshot = {
  kpiSnapshotId: string;
  companyId: string;
  kpiCode: string;
  periodStart: string;
  periodEnd: string;
  dimensionType: string;
  dimensionId: string | null;
  value: number;
  referenceValue: number | null;
  deltaValue: number | null;
  deltaPct: number | null;
  status: "ok" | "warning" | "critical" | "unknown";
  confidenceScore: number | null;
  sourceSnapshot: Record<string, unknown>;
  calculatedAt: string;
};

export type KpiSnapshotMap = Record<string, KpiSnapshot>;
```

---

## `src/types/evaluation.types.ts`

```ts
import type { Severity } from "./rule.types.js";

export type EvaluationContext = {
  companyId: string;
  userId: string | null;
  roleCodes: string[];
  periodStart: string;
  periodEnd: string;
  dimensionType?: string;
  dimensionId?: string | null;
  dryRun?: boolean;
  createActions?: boolean;
  createNotifications?: boolean;
};

export type ConditionDiagnostic = {
  kpi: string;
  metric: string;
  operator: string;
  expected?: unknown;
  actual?: unknown;
  passed: boolean;
  message: string;
};

export type RuleEvaluationResult = {
  ruleId: string;
  ruleCode: string;
  tensionCode: string;
  triggered: boolean;
  severity: Severity | null;
  confidenceScore: number | null;
  priorityScore: number | null;
  scoreImpact: number | null;
  diagnostics: ConditionDiagnostic[];
  missingKpis: string[];
  warnings: string[];
  error?: string;
};

export type EvaluationRunSummary = {
  companyId: string;
  periodStart: string;
  periodEnd: string;
  rulesEvaluated: number;
  rulesTriggered: number;
  tensionsCreated: number;
  tensionsUpdated: number;
  actionsCreated: number;
  errors: number;
  warnings: number;
  dryRun: boolean;
};
```

---

## `src/types/action.types.ts`

```ts
export type ActionTemplate = {
  actionCode: string;
  title: string;
  description: string;
  actionType: "corrective" | "preventive" | "follow_up" | "approval" | "analysis" | "manual";
  defaultPriority: "low" | "medium" | "high" | "critical";
  closureCriteria: string;
};
```

---

# 11. Configuración del evaluador

## `src/config/evaluator.config.ts`

```ts
export const evaluatorConfig = {
  defaultRoleCodes: ["integration_service", "faro_owner", "company_admin"],
  defaultCreateActions: true,
  defaultCreateNotifications: false,

  priority: {
    low: 25,
    medium: 50,
    high: 75,
    critical: 90
  },

  scoreImpactBySeverity: {
    low: -1,
    medium: -3,
    high: -6,
    critical: -10
  },

  defaultActionPriorityBySeverity: {
    low: "low",
    medium: "medium",
    high: "high",
    critical: "critical"
  } as const,

  idempotency: {
    reopenClosedTensions: false,
    updateExistingOpenTension: true,
    duplicateWindowDays: 30
  }
};
```

---

# 12. Conexión DB con contexto RLS

## `src/db/db.ts`

```ts
import pg from "pg";
import dotenv from "dotenv";

dotenv.config();

const { Pool } = pg;

if (!process.env.DATABASE_URL) {
  throw new Error("DATABASE_URL is required");
}

export const pool = new Pool({
  connectionString: process.env.DATABASE_URL
});

export async function withFaroDbContext<T>(
  context: {
    companyId: string;
    userId: string | null;
    roleCodes: string[];
  },
  fn: (client: pg.PoolClient) => Promise<T>
): Promise<T> {
  const client = await pool.connect();

  try {
    await client.query("BEGIN");

    await client.query(
      `SELECT set_config('app.company_id', $1, true)`,
      [context.companyId]
    );

    if (context.userId) {
      await client.query(
        `SELECT set_config('app.user_id', $1, true)`,
        [context.userId]
      );
    }

    await client.query(
      `SELECT set_config('app.role_codes', $1, true)`,
      [context.roleCodes.join(",")]
    );

    const result = await fn(client);

    await client.query("COMMIT");

    return result;
  } catch (error) {
    await client.query("ROLLBACK");
    throw error;
  } finally {
    client.release();
  }
}
```

---

# 13. Repositorio de reglas

## `src/db/ruleRepository.ts`

```ts
import type pg from "pg";
import type { RuleDefinition } from "../types/rule.types.js";

function mapRule(row: any): RuleDefinition {
  return {
    ruleId: row.rule_id,
    companyId: row.company_id,
    ruleCode: row.rule_code,
    name: row.name,
    description: row.description,
    ruleType: row.rule_type,
    ruleFormat: row.rule_format,
    ruleBody: row.rule_body,
    severityDefault: row.severity_default,
    isMvp: row.is_mvp,
    status: row.status,
    version: row.version
  };
}

export async function getActiveRulesForCompany(
  client: pg.PoolClient,
  companyId: string
): Promise<RuleDefinition[]> {
  const result = await client.query(
    `
    SELECT *
    FROM faro.rule_definitions
    WHERE status = 'active'
      AND rule_type = 'tension'
      AND (
        company_id IS NULL
        OR company_id = $1
      )
    ORDER BY
      CASE WHEN company_id = $1 THEN 0 ELSE 1 END,
      rule_code,
      version DESC
    `,
    [companyId]
  );

  const latestByRuleCode = new Map<string, RuleDefinition>();

  for (const row of result.rows) {
    const rule = mapRule(row);

    if (!latestByRuleCode.has(rule.ruleCode)) {
      latestByRuleCode.set(rule.ruleCode, rule);
    }
  }

  return Array.from(latestByRuleCode.values());
}
```

---

# 14. Repositorio de KPIs

## `src/db/kpiRepository.ts`

```ts
import type pg from "pg";
import type { KpiSnapshot, KpiSnapshotMap } from "../types/kpi.types.js";

function mapKpi(row: any): KpiSnapshot {
  return {
    kpiSnapshotId: row.kpi_snapshot_id,
    companyId: row.company_id,
    kpiCode: row.kpi_code,
    periodStart: row.period_start,
    periodEnd: row.period_end,
    dimensionType: row.dimension_type,
    dimensionId: row.dimension_id,
    value: Number(row.value),
    referenceValue: row.reference_value === null ? null : Number(row.reference_value),
    deltaValue: row.delta_value === null ? null : Number(row.delta_value),
    deltaPct: row.delta_pct === null ? null : Number(row.delta_pct),
    status: row.status,
    confidenceScore: row.confidence_score === null ? null : Number(row.confidence_score),
    sourceSnapshot: row.source_snapshot ?? {},
    calculatedAt: row.calculated_at
  };
}

export async function getKpiSnapshotsForRule(
  client: pg.PoolClient,
  params: {
    companyId: string;
    periodStart: string;
    periodEnd: string;
    kpiCodes: string[];
    dimensionType?: string;
    dimensionId?: string | null;
  }
): Promise<KpiSnapshotMap> {
  const result = await client.query(
    `
    SELECT *
    FROM faro.kpi_snapshots
    WHERE company_id = $1
      AND period_start = $2
      AND period_end = $3
      AND kpi_code = ANY($4)
      AND dimension_type = COALESCE($5, dimension_type)
      AND (
        $6::uuid IS NULL
        OR dimension_id = $6::uuid
      )
    `,
    [
      params.companyId,
      params.periodStart,
      params.periodEnd,
      params.kpiCodes,
      params.dimensionType ?? null,
      params.dimensionId ?? null
    ]
  );

  const map: KpiSnapshotMap = {};

  for (const row of result.rows) {
    const snapshot = mapKpi(row);
    map[snapshot.kpiCode] = snapshot;
  }

  return map;
}
```

---

# 15. Repositorio de evaluaciones

## `src/db/evaluationRepository.ts`

```ts
import type pg from "pg";
import type { RuleEvaluationResult } from "../types/evaluation.types.js";

export async function insertRuleEvaluation(
  client: pg.PoolClient,
  params: {
    companyId: string;
    ruleId: string;
    periodStart: string;
    periodEnd: string;
    result: RuleEvaluationResult;
    inputPayload: Record<string, unknown>;
    outputPayload: Record<string, unknown>;
  }
): Promise<string> {
  const insert = await client.query(
    `
    INSERT INTO faro.rule_evaluations (
      company_id,
      rule_id,
      period_start,
      period_end,
      result,
      severity,
      confidence_score,
      input_payload,
      output_payload,
      status,
      error_message
    )
    VALUES (
      $1,
      $2,
      $3,
      $4,
      $5,
      $6,
      $7,
      $8::jsonb,
      $9::jsonb,
      $10,
      $11
    )
    RETURNING rule_evaluation_id
    `,
    [
      params.companyId,
      params.ruleId,
      params.periodStart,
      params.periodEnd,
      params.result.triggered,
      params.result.severity,
      params.result.confidenceScore,
      JSON.stringify(params.inputPayload),
      JSON.stringify(params.outputPayload),
      params.result.error ? "failed" : "completed",
      params.result.error ?? null
    ]
  );

  return insert.rows[0].rule_evaluation_id;
}
```

---

# 16. Evaluador de condiciones

## `src/engine/conditionEvaluator.ts`

```ts
import type { Condition, ConditionGroup } from "../types/rule.types.js";
import type { KpiSnapshotMap } from "../types/kpi.types.js";
import type { ConditionDiagnostic } from "../types/evaluation.types.js";

function getMetricValue(condition: Condition, snapshots: KpiSnapshotMap): unknown {
  const snapshot = snapshots[condition.kpi];

  if (!snapshot) return undefined;

  switch (condition.metric) {
    case "value":
      return snapshot.value;
    case "reference_value":
      return snapshot.referenceValue;
    case "delta_value":
      return snapshot.deltaValue;
    case "delta_pct":
      return snapshot.deltaPct;
    case "confidence_score":
      return snapshot.confidenceScore;
    case "status":
      return snapshot.status;
    default:
      return (snapshot as any)[condition.metric];
  }
}

function compare(actual: unknown, operator: string, expected: unknown): boolean {
  if (operator === "exists") {
    return actual !== undefined && actual !== null;
  }

  if (operator === "missing") {
    return actual === undefined || actual === null;
  }

  if (operator === "in") {
    return Array.isArray(expected) && expected.includes(actual as any);
  }

  if (operator === "not_in") {
    return Array.isArray(expected) && !expected.includes(actual as any);
  }

  if (operator === "between") {
    if (!Array.isArray(expected) || expected.length !== 2) return false;
    if (typeof actual !== "number") return false;
    return actual >= Number(expected[0]) && actual <= Number(expected[1]);
  }

  if (typeof actual !== "number" || typeof expected !== "number") {
    if (operator === "==") return actual === expected;
    if (operator === "!=") return actual !== expected;
    return false;
  }

  switch (operator) {
    case ">":
      return actual > expected;
    case ">=":
      return actual >= expected;
    case "<":
      return actual < expected;
    case "<=":
      return actual <= expected;
    case "==":
      return actual === expected;
    case "!=":
      return actual !== expected;
    case "changed_by_pct":
      return Math.abs(actual) >= expected;
    case "older_than_days":
      return actual > expected;
    default:
      return false;
  }
}

function evaluateCondition(
  condition: Condition,
  snapshots: KpiSnapshotMap
): ConditionDiagnostic {
  const actual = getMetricValue(condition, snapshots);
  const passed = compare(actual, condition.operator, condition.value);

  return {
    kpi: condition.kpi,
    metric: condition.metric,
    operator: condition.operator,
    expected: condition.value,
    actual,
    passed,
    message: passed
      ? `Condition passed: ${condition.kpi}.${condition.metric} ${condition.operator} ${condition.value}`
      : `Condition failed: ${condition.kpi}.${condition.metric} ${condition.operator} ${condition.value}; actual=${actual}`
  };
}

export function evaluateConditionGroup(
  group: ConditionGroup,
  snapshots: KpiSnapshotMap
): {
  passed: boolean;
  diagnostics: ConditionDiagnostic[];
} {
  const diagnostics: ConditionDiagnostic[] = [];

  let allPassed = true;
  let anyPassed = true;
  let nonePassed = true;

  if (group.all?.length) {
    const results = group.all.map((condition) => evaluateCondition(condition, snapshots));
    diagnostics.push(...results);
    allPassed = results.every((result) => result.passed);
  }

  if (group.any?.length) {
    const results = group.any.map((condition) => evaluateCondition(condition, snapshots));
    diagnostics.push(...results);
    anyPassed = results.some((result) => result.passed);
  }

  if (group.none?.length) {
    const results = group.none.map((condition) => evaluateCondition(condition, snapshots));
    diagnostics.push(...results);
    nonePassed = results.every((result) => !result.passed);
  }

  return {
    passed: allPassed && anyPassed && nonePassed,
    diagnostics
  };
}
```

---

# 17. Validador de confianza

## `src/engine/confidenceValidator.ts`

```ts
import type { RuleDefinition } from "../types/rule.types.js";
import type { KpiSnapshotMap } from "../types/kpi.types.js";

export function validateConfidence(
  rule: RuleDefinition,
  snapshots: KpiSnapshotMap
): {
  passed: boolean;
  confidenceScore: number | null;
  warnings: string[];
} {
  const requiredKpis = rule.ruleBody.data_requirements.required_kpis;
  const minimum = rule.ruleBody.data_requirements.minimum_confidence_score;

  const confidenceValues: number[] = [];
  const warnings: string[] = [];

  for (const kpiCode of requiredKpis) {
    const snapshot = snapshots[kpiCode];

    if (!snapshot) continue;

    if (snapshot.confidenceScore === null) {
      warnings.push(`KPI ${kpiCode} has no confidence_score`);
      continue;
    }

    confidenceValues.push(snapshot.confidenceScore);

    if (snapshot.confidenceScore < minimum) {
      warnings.push(
        `KPI ${kpiCode} confidence ${snapshot.confidenceScore} is below minimum ${minimum}`
      );
    }
  }

  if (confidenceValues.length === 0) {
    return {
      passed: false,
      confidenceScore: null,
      warnings: ["No confidence scores available"]
    };
  }

  const avg =
    confidenceValues.reduce((sum, value) => sum + value, 0) / confidenceValues.length;

  return {
    passed: avg >= minimum,
    confidenceScore: Number(avg.toFixed(2)),
    warnings
  };
}
```

---

# 18. Calculador de severidad

## `src/engine/severityCalculator.ts`

```ts
import type { RuleDefinition, Severity } from "../types/rule.types.js";
import type { KpiSnapshotMap } from "../types/kpi.types.js";
import { evaluateConditionGroup } from "./conditionEvaluator.js";

export function calculateSeverity(
  rule: RuleDefinition,
  snapshots: KpiSnapshotMap
): Severity {
  const severity = rule.ruleBody.severity;

  for (const escalation of severity.escalation ?? []) {
    const result = evaluateConditionGroup(escalation.when, snapshots);

    if (result.passed) {
      return escalation.set;
    }
  }

  return severity.default;
}
```

---

# 19. Calculador de prioridad

## `src/engine/priorityCalculator.ts`

```ts
import type { Severity } from "../types/rule.types.js";
import { evaluatorConfig } from "../config/evaluator.config.js";

export function calculatePriorityScore(params: {
  severity: Severity;
  confidenceScore: number | null;
  scoreImpact: number | null;
}): number {
  const base = evaluatorConfig.priority[params.severity];

  const confidenceAdjustment =
    params.confidenceScore === null
      ? -10
      : params.confidenceScore >= 85
        ? 5
        : params.confidenceScore < 70
          ? -10
          : 0;

  const impactAdjustment =
    params.scoreImpact === null
      ? 0
      : Math.min(10, Math.abs(params.scoreImpact));

  const result = base + confidenceAdjustment + impactAdjustment;

  return Math.max(0, Math.min(100, Number(result.toFixed(2))));
}
```

---

# 20. Cálculo de impacto Score

## `src/services/scoreImpactService.ts`

```ts
import type { Severity } from "../types/rule.types.js";

export function calculateScoreImpact(params: {
  base: number;
  max?: number;
  severity: Severity;
}): number {
  const severityMultiplier: Record<Severity, number> = {
    low: 0.5,
    medium: 0.75,
    high: 1,
    critical: 1.25
  };

  const raw = params.base * severityMultiplier[params.severity];

  if (params.max === undefined) {
    return Number(raw.toFixed(2));
  }

  const maxAbs = Math.abs(params.max);

  if (raw < 0) {
    return Number(Math.max(raw, -maxAbs).toFixed(2));
  }

  return Number(Math.min(raw, maxAbs).toFixed(2));
}
```

---

# 21. Diagnóstico explicable

## `src/engine/diagnosticsBuilder.ts`

```ts
import type { ConditionDiagnostic } from "../types/evaluation.types.js";

export function buildDiagnosisText(params: {
  template: string;
  diagnostics: ConditionDiagnostic[];
}): string {
  const passed = params.diagnostics.filter((d) => d.passed);
  const failed = params.diagnostics.filter((d) => !d.passed);

  const passedText = passed
    .map((d) => `- ${d.kpi}.${d.metric}: ${d.actual} ${d.operator} ${d.expected}`)
    .join("\n");

  const failedText = failed
    .map((d) => `- NO cumplió ${d.kpi}.${d.metric}: actual=${d.actual}, esperado ${d.operator} ${d.expected}`)
    .join("\n");

  return [
    params.template.trim(),
    "",
    "Condiciones cumplidas:",
    passedText || "- Ninguna",
    "",
    "Condiciones no cumplidas:",
    failedText || "- Ninguna"
  ].join("\n");
}
```

---

# 22. Idempotencia

El motor no debe crear 15 veces la misma tensión si se corre 15 veces.

## Regla MVP de idempotencia

Una tensión es considerada existente si cumple:

```text
same company_id
+ same tension_code
+ same period_start / period_end en payload
+ status abierto
```

Estados abiertos:

```text
new
in_analysis
in_execution
in_verification
expired
escalated
```

Estados cerrados:

```text
closed
rejected
```

## `src/engine/idempotency.ts`

```ts
export const OPEN_TENSION_STATUSES = [
  "new",
  "in_analysis",
  "in_execution",
  "in_verification",
  "expired",
  "escalated"
];

export function buildEvaluationKey(params: {
  companyId: string;
  tensionCode: string;
  periodStart: string;
  periodEnd: string;
  dimensionType?: string;
  dimensionId?: string | null;
}): string {
  return [
    params.companyId,
    params.tensionCode,
    params.periodStart,
    params.periodEnd,
    params.dimensionType ?? "company",
    params.dimensionId ?? "null"
  ].join(":");
}
```

---

# 23. Repositorio de tensiones

## `src/db/tensionRepository.ts`

```ts
import type pg from "pg";
import { OPEN_TENSION_STATUSES } from "../engine/idempotency.js";

export async function findOpenTension(
  client: pg.PoolClient,
  params: {
    companyId: string;
    tensionCode: string;
    periodStart: string;
    periodEnd: string;
    dimensionType?: string;
    dimensionId?: string | null;
  }
): Promise<any | null> {
  const result = await client.query(
    `
    SELECT *
    FROM faro.tensions
    WHERE company_id = $1
      AND tension_code = $2
      AND status = ANY($3)
      AND payload->>'period_start' = $4
      AND payload->>'period_end' = $5
      AND COALESCE(payload->>'dimension_type', 'company') = $6
      AND COALESCE(payload->>'dimension_id', 'null') = $7
    ORDER BY detected_at DESC
    LIMIT 1
    `,
    [
      params.companyId,
      params.tensionCode,
      OPEN_TENSION_STATUSES,
      params.periodStart,
      params.periodEnd,
      params.dimensionType ?? "company",
      params.dimensionId ?? "null"
    ]
  );

  return result.rows[0] ?? null;
}

export async function createTension(
  client: pg.PoolClient,
  params: {
    companyId: string;
    ruleEvaluationId: string;
    tensionCode: string;
    title: string;
    description: string;
    severity: string;
    priorityScore: number;
    confidenceScore: number | null;
    responsibleUserId: string | null;
    dueAt: string | null;
    scoreImpact: number | null;
    payload: Record<string, unknown>;
  }
): Promise<string> {
  const result = await client.query(
    `
    INSERT INTO faro.tensions (
      company_id,
      rule_evaluation_id,
      tension_code,
      title,
      description,
      responsible_user_id,
      severity,
      priority_score,
      confidence_score,
      status,
      due_at,
      score_impact,
      payload
    )
    VALUES (
      $1, $2, $3, $4, $5,
      $6, $7, $8, $9,
      'new',
      $10,
      $11,
      $12::jsonb
    )
    RETURNING tension_id
    `,
    [
      params.companyId,
      params.ruleEvaluationId,
      params.tensionCode,
      params.title,
      params.description,
      params.responsibleUserId,
      params.severity,
      params.priorityScore,
      params.confidenceScore,
      params.dueAt,
      params.scoreImpact,
      JSON.stringify(params.payload)
    ]
  );

  return result.rows[0].tension_id;
}

export async function updateExistingTension(
  client: pg.PoolClient,
  params: {
    tensionId: string;
    ruleEvaluationId: string;
    severity: string;
    priorityScore: number;
    confidenceScore: number | null;
    description: string;
    scoreImpact: number | null;
    payload: Record<string, unknown>;
  }
): Promise<void> {
  await client.query(
    `
    UPDATE faro.tensions
    SET
      rule_evaluation_id = $2,
      severity = $3,
      priority_score = $4,
      confidence_score = $5,
      description = $6,
      score_impact = $7,
      payload = payload || $8::jsonb,
      updated_at = now()
    WHERE tension_id = $1
    `,
    [
      params.tensionId,
      params.ruleEvaluationId,
      params.severity,
      params.priorityScore,
      params.confidenceScore,
      params.description,
      params.scoreImpact,
      JSON.stringify(params.payload)
    ]
  );
}
```

---

# 24. Resolución de responsables

En MVP se puede resolver responsable por rol.

## Política inicial

| Rol lógico           | Usuario esperado                    |
| -------------------- | ----------------------------------- |
| `commercial_manager` | Usuario con rol comercial           |
| `finance_manager`    | Usuario con rol finanzas            |
| `stock_manager`      | Usuario con rol stock               |
| `purchasing_manager` | Usuario compras / área manager      |
| `general_manager`    | Gerente general                     |
| `director`           | Director                            |
| `data_owner`         | Company admin / integration service |

## SQL conceptual

```sql
SELECT u.user_id
FROM faro.users u
JOIN faro.user_roles ur ON ur.user_id = u.user_id
JOIN faro.roles r ON r.role_id = ur.role_id
WHERE u.company_id = $1
  AND r.role_code = ANY($2)
  AND u.status = 'active'
LIMIT 1;
```

## `src/services/tensionService.ts`

```ts
import type pg from "pg";

const ROLE_MAPPING: Record<string, string[]> = {
  commercial_manager: ["commercial_user", "area_manager", "general_manager"],
  finance_manager: ["finance_user", "area_manager", "general_manager"],
  stock_manager: ["stock_user", "area_manager", "general_manager"],
  purchasing_manager: ["area_manager", "general_manager"],
  general_manager: ["general_manager", "director"],
  director: ["director", "company_admin"],
  data_owner: ["company_admin", "integration_service", "general_manager"]
};

export async function resolveResponsibleUser(
  client: pg.PoolClient,
  params: {
    companyId: string;
    logicalRole: string;
  }
): Promise<string | null> {
  const roleCodes = ROLE_MAPPING[params.logicalRole] ?? ["general_manager", "director"];

  const result = await client.query(
    `
    SELECT u.user_id
    FROM faro.users u
    JOIN faro.user_roles ur
      ON ur.user_id = u.user_id
    JOIN faro.roles r
      ON r.role_id = ur.role_id
    WHERE u.company_id = $1
      AND ur.company_id = $1
      AND r.role_code = ANY($2)
      AND u.status = 'active'
      AND ur.status = 'active'
    ORDER BY
      CASE r.role_code
        WHEN $3 THEN 0
        ELSE 1
      END,
      u.created_at ASC
    LIMIT 1
    `,
    [params.companyId, roleCodes, roleCodes[0]]
  );

  return result.rows[0]?.user_id ?? null;
}
```

---

# 25. Repositorio de acciones

## `src/db/actionRepository.ts`

```ts
import type pg from "pg";

export async function findExistingActionForTension(
  client: pg.PoolClient,
  params: {
    companyId: string;
    tensionId: string;
    actionCode: string;
  }
): Promise<any | null> {
  const result = await client.query(
    `
    SELECT *
    FROM faro.actions
    WHERE company_id = $1
      AND tension_id = $2
      AND action_code = $3
      AND status NOT IN ('closed', 'cancelled', 'rejected')
    LIMIT 1
    `,
    [params.companyId, params.tensionId, params.actionCode]
  );

  return result.rows[0] ?? null;
}

export async function createAction(
  client: pg.PoolClient,
  params: {
    companyId: string;
    tensionId: string;
    actionCode: string;
    title: string;
    description: string;
    responsibleUserId: string | null;
    approverUserId: string | null;
    priority: string;
    dueDate: string;
    evidenceRequired: boolean;
    closureCriteria: string;
    createdBy: string | null;
    payload: Record<string, unknown>;
  }
): Promise<string> {
  const result = await client.query(
    `
    INSERT INTO faro.actions (
      company_id,
      tension_id,
      action_code,
      title,
      description,
      action_type,
      responsible_user_id,
      approver_user_id,
      status,
      priority,
      due_date,
      evidence_required,
      closure_criteria,
      created_by,
      payload
    )
    VALUES (
      $1, $2, $3, $4, $5,
      'corrective',
      $6, $7,
      'new',
      $8,
      $9,
      $10,
      $11,
      $12,
      $13::jsonb
    )
    RETURNING action_id
    `,
    [
      params.companyId,
      params.tensionId,
      params.actionCode,
      params.title,
      params.description,
      params.responsibleUserId,
      params.approverUserId,
      params.priority,
      params.dueDate,
      params.evidenceRequired,
      params.closureCriteria,
      params.createdBy,
      JSON.stringify(params.payload)
    ]
  );

  return result.rows[0].action_id;
}
```

---

# 26. Catálogo mínimo de acciones

En MVP puede vivir como archivo local. Luego debe pasar a tabla `action_definitions`.

## `src/services/actionService.ts`

```ts
import type pg from "pg";
import { addDays } from "../utils/dates.js";
import { createAction, findExistingActionForTension } from "../db/actionRepository.js";

const ACTION_CATALOG: Record<string, { title: string; description: string; closureCriteria: string }> = {
  "ACT-COM-001": {
    title: "Revisar política de descuentos",
    description: "Analizar descuentos aplicados y ajustar política comercial por producto, vendedor y cliente.",
    closureCriteria: "Nueva política de descuentos aprobada y cargada como evidencia."
  },
  "ACT-COM-002": {
    title: "Revisar comisiones vinculadas a margen",
    description: "Evaluar si el esquema de comisión promueve ventas de bajo margen.",
    closureCriteria: "Propuesta de comisión revisada y aprobada."
  },
  "ACT-COM-003": {
    title: "Bloquear descuentos fuera de autorización",
    description: "Definir límites de descuento y circuito de aprobación.",
    closureCriteria: "Regla de autorización activa o política aprobada."
  },
  "ACT-FIN-001": {
    title: "Priorizar cobranza de clientes vencidos",
    description: "Gestionar clientes con mora relevante y registrar resultado.",
    closureCriteria: "Cliente contactado, acuerdo de pago o comprobante de gestión."
  },
  "ACT-STK-001": {
    title: "Generar reposición priorizada",
    description: "Priorizar reposición de productos críticos.",
    closureCriteria: "Orden de compra emitida o proveedor confirmado."
  },
  "ACT-OPS-001": {
    title: "Regularizar acciones vencidas",
    description: "Revisar acciones abiertas vencidas y definir cierre, evidencia o escalamiento.",
    closureCriteria: "Acciones regularizadas con evidencia o escaladas."
  },
  "ACT-OPS-002": {
    title: "Exigir evidencia de cierre",
    description: "Solicitar evidencia faltante para validar cierre real de acciones.",
    closureCriteria: "Evidencia aprobada por responsable correspondiente."
  },
  "ACT-DIR-001": {
    title: "Escalar tensión a dirección",
    description: "Elevar tensión crítica para decisión ejecutiva.",
    closureCriteria: "Decisión ejecutiva documentada."
  }
};

export async function createRecommendedActions(params: {
  client: pg.PoolClient;
  companyId: string;
  tensionId: string;
  actionCodes: string[];
  responsibleUserId: string | null;
  approverUserId: string | null;
  priority: string;
  slaDays: number;
  createdBy: string | null;
  evidenceRequiredCodes: string[];
}): Promise<string[]> {
  const createdActionIds: string[] = [];

  for (const actionCode of params.actionCodes) {
    const template = ACTION_CATALOG[actionCode];

    if (!template) {
      continue;
    }

    const existing = await findExistingActionForTension(params.client, {
      companyId: params.companyId,
      tensionId: params.tensionId,
      actionCode
    });

    if (existing) {
      continue;
    }

    const dueDate = addDays(new Date(), params.slaDays);

    const actionId = await createAction(params.client, {
      companyId: params.companyId,
      tensionId: params.tensionId,
      actionCode,
      title: template.title,
      description: template.description,
      responsibleUserId: params.responsibleUserId,
      approverUserId: params.approverUserId,
      priority: params.priority,
      dueDate,
      evidenceRequired: true,
      closureCriteria: template.closureCriteria,
      createdBy: params.createdBy,
      payload: {
        created_by_engine: true,
        evidence_required_codes: params.evidenceRequiredCodes
      }
    });

    createdActionIds.push(actionId);
  }

  return createdActionIds;
}
```

---

# 27. Utilidad de fechas

## `src/utils/dates.ts`

```ts
export function addDays(date: Date, days: number): string {
  const copy = new Date(date);
  copy.setDate(copy.getDate() + days);
  return copy.toISOString().slice(0, 10);
}
```

---

# 28. Evaluador principal de una regla

## `src/engine/evaluator.ts`

```ts
import type { RuleDefinition } from "../types/rule.types.js";
import type { KpiSnapshotMap } from "../types/kpi.types.js";
import type { RuleEvaluationResult } from "../types/evaluation.types.js";
import { validateConfidence } from "./confidenceValidator.js";
import { evaluateConditionGroup } from "./conditionEvaluator.js";
import { calculateSeverity } from "./severityCalculator.js";
import { calculateScoreImpact } from "../services/scoreImpactService.js";
import { calculatePriorityScore } from "./priorityCalculator.js";

export function evaluateRule(params: {
  rule: RuleDefinition;
  snapshots: KpiSnapshotMap;
}): RuleEvaluationResult {
  const { rule, snapshots } = params;

  const requiredKpis = rule.ruleBody.data_requirements.required_kpis;

  const missingKpis = requiredKpis.filter((kpiCode) => !snapshots[kpiCode]);

  if (missingKpis.length > 0) {
    const policy = rule.ruleBody.data_requirements.missing_data_policy;

    if (policy === "do_not_trigger") {
      return {
        ruleId: rule.ruleId,
        ruleCode: rule.ruleCode,
        tensionCode: rule.ruleBody.tension_code,
        triggered: false,
        severity: null,
        confidenceScore: null,
        priorityScore: null,
        scoreImpact: null,
        diagnostics: [],
        missingKpis,
        warnings: [`Missing KPIs: ${missingKpis.join(", ")}`]
      };
    }
  }

  const confidence = validateConfidence(rule, snapshots);

  if (!confidence.passed && rule.ruleBody.data_requirements.missing_data_policy === "do_not_trigger") {
    return {
      ruleId: rule.ruleId,
      ruleCode: rule.ruleCode,
      tensionCode: rule.ruleBody.tension_code,
      triggered: false,
      severity: null,
      confidenceScore: confidence.confidenceScore,
      priorityScore: null,
      scoreImpact: null,
      diagnostics: [],
      missingKpis,
      warnings: confidence.warnings
    };
  }

  const conditionResult = evaluateConditionGroup(rule.ruleBody.conditions, snapshots);

  if (!conditionResult.passed) {
    return {
      ruleId: rule.ruleId,
      ruleCode: rule.ruleCode,
      tensionCode: rule.ruleBody.tension_code,
      triggered: false,
      severity: null,
      confidenceScore: confidence.confidenceScore,
      priorityScore: null,
      scoreImpact: null,
      diagnostics: conditionResult.diagnostics,
      missingKpis,
      warnings: confidence.warnings
    };
  }

  const severity = calculateSeverity(rule, snapshots);

  const scoreImpact = calculateScoreImpact({
    base: rule.ruleBody.output.score_impact.base,
    max: rule.ruleBody.output.score_impact.max,
    severity
  });

  const priorityScore = calculatePriorityScore({
    severity,
    confidenceScore: confidence.confidenceScore,
    scoreImpact
  });

  return {
    ruleId: rule.ruleId,
    ruleCode: rule.ruleCode,
    tensionCode: rule.ruleBody.tension_code,
    triggered: true,
    severity,
    confidenceScore: confidence.confidenceScore,
    priorityScore,
    scoreImpact,
    diagnostics: conditionResult.diagnostics,
    missingKpis,
    warnings: confidence.warnings
  };
}
```

---

# 29. Servicio de evaluación completo

## `src/services/evaluationService.ts`

```ts
import type pg from "pg";
import type {
  EvaluationContext,
  EvaluationRunSummary
} from "../types/evaluation.types.js";
import type { RuleDefinition } from "../types/rule.types.js";

import { getActiveRulesForCompany } from "../db/ruleRepository.js";
import { getKpiSnapshotsForRule } from "../db/kpiRepository.js";
import { insertRuleEvaluation } from "../db/evaluationRepository.js";
import { evaluateRule } from "../engine/evaluator.js";
import { buildDiagnosisText } from "../engine/diagnosticsBuilder.js";
import { findOpenTension, createTension, updateExistingTension } from "../db/tensionRepository.js";
import { resolveResponsibleUser } from "./tensionService.js";
import { createRecommendedActions } from "./actionService.js";
import { evaluatorConfig } from "../config/evaluator.config.js";

function buildInputPayload(rule: RuleDefinition, snapshots: Record<string, unknown>) {
  return {
    rule_code: rule.ruleCode,
    tension_code: rule.ruleBody.tension_code,
    required_kpis: rule.ruleBody.data_requirements.required_kpis,
    snapshots
  };
}

function buildOutputPayload(params: {
  result: ReturnType<typeof evaluateRule>;
  rule: RuleDefinition;
}) {
  return {
    triggered: params.result.triggered,
    severity: params.result.severity,
    confidence_score: params.result.confidenceScore,
    priority_score: params.result.priorityScore,
    score_impact: params.result.scoreImpact,
    diagnostics: params.result.diagnostics,
    warnings: params.result.warnings,
    missing_kpis: params.result.missingKpis,
    output: params.rule.ruleBody.output
  };
}

function mapSeverityToPriority(severity: string | null): "low" | "medium" | "high" | "critical" {
  if (severity === "critical") return "critical";
  if (severity === "high") return "high";
  if (severity === "medium") return "medium";
  return "low";
}

export async function runEvaluation(
  client: pg.PoolClient,
  context: EvaluationContext
): Promise<EvaluationRunSummary> {
  const summary: EvaluationRunSummary = {
    companyId: context.companyId,
    periodStart: context.periodStart,
    periodEnd: context.periodEnd,
    rulesEvaluated: 0,
    rulesTriggered: 0,
    tensionsCreated: 0,
    tensionsUpdated: 0,
    actionsCreated: 0,
    errors: 0,
    warnings: 0,
    dryRun: Boolean(context.dryRun)
  };

  const rules = await getActiveRulesForCompany(client, context.companyId);

  for (const rule of rules) {
    summary.rulesEvaluated++;

    try {
      const snapshots = await getKpiSnapshotsForRule(client, {
        companyId: context.companyId,
        periodStart: context.periodStart,
        periodEnd: context.periodEnd,
        kpiCodes: rule.ruleBody.data_requirements.required_kpis,
        dimensionType: context.dimensionType ?? "company",
        dimensionId: context.dimensionId ?? null
      });

      const result = evaluateRule({
        rule,
        snapshots
      });

      summary.warnings += result.warnings.length;

      const inputPayload = buildInputPayload(rule, snapshots);
      const outputPayload = buildOutputPayload({ result, rule });

      let ruleEvaluationId = "dry-run";

      if (!context.dryRun) {
        ruleEvaluationId = await insertRuleEvaluation(client, {
          companyId: context.companyId,
          ruleId: rule.ruleId,
          periodStart: context.periodStart,
          periodEnd: context.periodEnd,
          result,
          inputPayload,
          outputPayload
        });
      }

      if (!result.triggered) {
        continue;
      }

      summary.rulesTriggered++;

      if (!rule.ruleBody.output.create_tension) {
        continue;
      }

      const responsibleUserId = await resolveResponsibleUser(client, {
        companyId: context.companyId,
        logicalRole: rule.ruleBody.output.assign_to_role
      });

      const approverUserId = rule.ruleBody.output.approver_role
        ? await resolveResponsibleUser(client, {
            companyId: context.companyId,
            logicalRole: rule.ruleBody.output.approver_role
          })
        : null;

      const diagnosis = buildDiagnosisText({
        template: rule.ruleBody.output.diagnosis_template,
        diagnostics: result.diagnostics
      });

      const existing = await findOpenTension(client, {
        companyId: context.companyId,
        tensionCode: rule.ruleBody.tension_code,
        periodStart: context.periodStart,
        periodEnd: context.periodEnd,
        dimensionType: context.dimensionType ?? "company",
        dimensionId: context.dimensionId ?? null
      });

      let tensionId: string;

      const tensionPayload = {
        rule_code: rule.ruleCode,
        rule_version: rule.version,
        period_start: context.periodStart,
        period_end: context.periodEnd,
        dimension_type: context.dimensionType ?? "company",
        dimension_id: context.dimensionId ?? "null",
        diagnostics: result.diagnostics,
        warnings: result.warnings,
        evidence_required: rule.ruleBody.output.evidence_required
      };

      if (context.dryRun) {
        tensionId = "dry-run";
      } else if (existing) {
        tensionId = existing.tension_id;

        await updateExistingTension(client, {
          tensionId,
          ruleEvaluationId,
          severity: result.severity ?? rule.severityDefault,
          priorityScore: result.priorityScore ?? 0,
          confidenceScore: result.confidenceScore,
          description: diagnosis,
          scoreImpact: result.scoreImpact,
          payload: tensionPayload
        });

        summary.tensionsUpdated++;
      } else {
        tensionId = await createTension(client, {
          companyId: context.companyId,
          ruleEvaluationId,
          tensionCode: rule.ruleBody.tension_code,
          title: rule.ruleBody.output.title,
          description: diagnosis,
          severity: result.severity ?? rule.severityDefault,
          priorityScore: result.priorityScore ?? 0,
          confidenceScore: result.confidenceScore,
          responsibleUserId,
          dueAt: null,
          scoreImpact: result.scoreImpact,
          payload: tensionPayload
        });

        summary.tensionsCreated++;
      }

      const shouldCreateActions =
        context.createActions ?? evaluatorConfig.defaultCreateActions;

      if (shouldCreateActions && !context.dryRun) {
        const createdActions = await createRecommendedActions({
          client,
          companyId: context.companyId,
          tensionId,
          actionCodes: rule.ruleBody.output.recommended_actions,
          responsibleUserId,
          approverUserId,
          priority: mapSeverityToPriority(result.severity),
          slaDays: rule.ruleBody.output.default_sla_days,
          createdBy: context.userId,
          evidenceRequiredCodes: rule.ruleBody.output.evidence_required
        });

        summary.actionsCreated += createdActions.length;
      }
    } catch (error) {
      summary.errors++;
      console.error(`Error evaluating rule ${rule.ruleCode}:`, error);
    }
  }

  return summary;
}
```

---

# 30. CLI del motor

## `src/cli.ts`

```ts
#!/usr/bin/env node

import { Command } from "commander";
import dotenv from "dotenv";
import { evaluateCommand } from "./commands/evaluateCommand.js";
import { dryRunCommand } from "./commands/dryRunCommand.js";
import { explainCommand } from "./commands/explainCommand.js";

dotenv.config();

const program = new Command();

program
  .name("faro-evaluator")
  .description("FARO Connect MVP Evaluation Engine")
  .version("1.0.0");

program
  .command("evaluate")
  .description("Evaluate active FARO rules and create tensions/actions")
  .requiredOption("--company-id <companyId>", "Company ID")
  .requiredOption("--period-start <periodStart>", "Period start YYYY-MM-DD")
  .requiredOption("--period-end <periodEnd>", "Period end YYYY-MM-DD")
  .option("--user-id <userId>", "User ID")
  .option("--roles <roles>", "Comma-separated role codes", "integration_service,faro_owner,company_admin")
  .option("--no-actions", "Do not create actions")
  .action(async (options) => {
    await evaluateCommand({
      companyId: options.companyId,
      periodStart: options.periodStart,
      periodEnd: options.periodEnd,
      userId: options.userId ?? null,
      roleCodes: options.roles.split(","),
      createActions: options.actions
    });
  });

program
  .command("dry-run")
  .description("Evaluate active FARO rules without writing results")
  .requiredOption("--company-id <companyId>", "Company ID")
  .requiredOption("--period-start <periodStart>", "Period start YYYY-MM-DD")
  .requiredOption("--period-end <periodEnd>", "Period end YYYY-MM-DD")
  .option("--user-id <userId>", "User ID")
  .option("--roles <roles>", "Comma-separated role codes", "integration_service,faro_owner,company_admin")
  .action(async (options) => {
    await dryRunCommand({
      companyId: options.companyId,
      periodStart: options.periodStart,
      periodEnd: options.periodEnd,
      userId: options.userId ?? null,
      roleCodes: options.roles.split(",")
    });
  });

program
  .command("explain")
  .description("Explain evaluation context and available rules")
  .requiredOption("--company-id <companyId>", "Company ID")
  .requiredOption("--period-start <periodStart>", "Period start YYYY-MM-DD")
  .requiredOption("--period-end <periodEnd>", "Period end YYYY-MM-DD")
  .action(async (options) => {
    await explainCommand({
      companyId: options.companyId,
      periodStart: options.periodStart,
      periodEnd: options.periodEnd
    });
  });

program.parseAsync(process.argv);
```

---

# 31. Comando evaluate

## `src/commands/evaluateCommand.ts`

```ts
import { withFaroDbContext } from "../db/db.js";
import { runEvaluation } from "../services/evaluationService.js";

export async function evaluateCommand(params: {
  companyId: string;
  periodStart: string;
  periodEnd: string;
  userId: string | null;
  roleCodes: string[];
  createActions: boolean;
}) {
  const summary = await withFaroDbContext(
    {
      companyId: params.companyId,
      userId: params.userId,
      roleCodes: params.roleCodes
    },
    async (client) => {
      return runEvaluation(client, {
        companyId: params.companyId,
        userId: params.userId,
        roleCodes: params.roleCodes,
        periodStart: params.periodStart,
        periodEnd: params.periodEnd,
        dryRun: false,
        createActions: params.createActions
      });
    }
  );

  console.log("✅ FARO evaluation completed");
  console.table(summary);
}
```

---

# 32. Comando dry-run

## `src/commands/dryRunCommand.ts`

```ts
import { withFaroDbContext } from "../db/db.js";
import { runEvaluation } from "../services/evaluationService.js";

export async function dryRunCommand(params: {
  companyId: string;
  periodStart: string;
  periodEnd: string;
  userId: string | null;
  roleCodes: string[];
}) {
  const summary = await withFaroDbContext(
    {
      companyId: params.companyId,
      userId: params.userId,
      roleCodes: params.roleCodes
    },
    async (client) => {
      return runEvaluation(client, {
        companyId: params.companyId,
        userId: params.userId,
        roleCodes: params.roleCodes,
        periodStart: params.periodStart,
        periodEnd: params.periodEnd,
        dryRun: true,
        createActions: false
      });
    }
  );

  console.log("🧪 FARO dry-run completed");
  console.table(summary);
}
```

---

# 33. Comando explain

## `src/commands/explainCommand.ts`

```ts
import { withFaroDbContext } from "../db/db.js";
import { getActiveRulesForCompany } from "../db/ruleRepository.js";

export async function explainCommand(params: {
  companyId: string;
  periodStart: string;
  periodEnd: string;
}) {
  await withFaroDbContext(
    {
      companyId: params.companyId,
      userId: null,
      roleCodes: ["integration_service", "faro_owner", "company_admin"]
    },
    async (client) => {
      const rules = await getActiveRulesForCompany(client, params.companyId);

      console.log("FARO Evaluation Context");
      console.log("-----------------------");
      console.log(`Company: ${params.companyId}`);
      console.log(`Period: ${params.periodStart} → ${params.periodEnd}`);
      console.log(`Active rules: ${rules.length}`);
      console.log("");

      for (const rule of rules) {
        console.log(`${rule.ruleCode} · ${rule.name}`);
        console.log(`  Tension: ${rule.ruleBody.tension_code}`);
        console.log(`  KPIs: ${rule.ruleBody.data_requirements.required_kpis.join(", ")}`);
        console.log(`  Severity default: ${rule.severityDefault}`);
        console.log("");
      }
    }
  );
}
```

---

# 34. Export principal

## `src/index.ts`

```ts
export { runEvaluation } from "./services/evaluationService.js";
export { evaluateRule } from "./engine/evaluator.js";
export { evaluateConditionGroup } from "./engine/conditionEvaluator.js";
export { calculateSeverity } from "./engine/severityCalculator.js";
export { calculatePriorityScore } from "./engine/priorityCalculator.js";
```

---

# 35. Comandos esperados

## 35.1 Dry-run

```bash
tsx src/cli.ts dry-run \
  --company-id 10000000-0000-0000-0000-000000000001 \
  --period-start 2026-05-01 \
  --period-end 2026-05-31 \
  --user-id 12000000-0000-0000-0000-000000000001 \
  --roles integration_service,faro_owner,company_admin
```

Salida esperada:

```text
🧪 FARO dry-run completed
rulesEvaluated: 30
rulesTriggered: 5
tensionsCreated: 0
actionsCreated: 0
dryRun: true
```

## 35.2 Evaluación real

```bash
tsx src/cli.ts evaluate \
  --company-id 10000000-0000-0000-0000-000000000001 \
  --period-start 2026-05-01 \
  --period-end 2026-05-31 \
  --user-id 12000000-0000-0000-0000-000000000001 \
  --roles integration_service,faro_owner,company_admin
```

Salida esperada:

```text
✅ FARO evaluation completed
rulesEvaluated: 30
rulesTriggered: 5
tensionsCreated: 5
actionsCreated: 7
errors: 0
```

## 35.3 Explain

```bash
tsx src/cli.ts explain \
  --company-id 10000000-0000-0000-0000-000000000001 \
  --period-start 2026-05-01 \
  --period-end 2026-05-31
```

---

# 36. Comprobaciones SQL posteriores

## 36.1 Evaluaciones creadas

```sql
SELECT
  rd.rule_code,
  rd.name,
  re.result,
  re.severity,
  re.confidence_score,
  re.status,
  re.evaluated_at
FROM faro.rule_evaluations re
JOIN faro.rule_definitions rd
  ON rd.rule_id = re.rule_id
WHERE re.company_id = '10000000-0000-0000-0000-000000000001'
ORDER BY re.evaluated_at DESC;
```

## 36.2 Tensiones creadas

```sql
SELECT
  tension_code,
  title,
  severity,
  priority_score,
  confidence_score,
  status,
  score_impact,
  detected_at
FROM faro.tensions
WHERE company_id = '10000000-0000-0000-0000-000000000001'
ORDER BY priority_score DESC;
```

## 36.3 Acciones creadas

```sql
SELECT
  a.action_code,
  a.title,
  a.status,
  a.priority,
  a.due_date,
  u.full_name AS responsible
FROM faro.actions a
LEFT JOIN faro.users u
  ON u.user_id = a.responsible_user_id
WHERE a.company_id = '10000000-0000-0000-0000-000000000001'
ORDER BY a.due_date ASC;
```

## 36.4 Diagnóstico de una tensión

```sql
SELECT
  tension_code,
  title,
  description,
  payload
FROM faro.tensions
WHERE company_id = '10000000-0000-0000-0000-000000000001'
  AND tension_code = 'TNS-001'
ORDER BY detected_at DESC
LIMIT 1;
```

---

# 37. Caso esperado · Crecimiento no rentable

Con los datos demo, la regla TNS-001 debería disparar porque:

| KPI                |                          Valor |
| ------------------ | -----------------------------: |
| Ventas netas       |                           +18% |
| Margen bruto       |                      28% → 21% |
| Descuento promedio |                       6% → 12% |
| Confianza promedio |                           > 75 |
| Severidad          |                        Crítica |
| Score impact       |            Aproximadamente -10 |
| Responsable        |                      Comercial |
| Acción             | Revisar política de descuentos |

Resultado esperado:

```text
TNS-001 · Crecimiento no rentable
status: new / in_analysis
severity: critical
priority_score: > 90
```

---

# 38. Política de idempotencia

## 38.1 Si el motor corre una vez

Debe crear:

```text
rule_evaluation
tension
actions
```

## 38.2 Si el motor corre dos veces para el mismo período

Debe crear:

```text
nueva rule_evaluation
```

Pero no debe duplicar:

```text
tension abierta existente
actions abiertas existentes
```

Debe actualizar:

```text
severity
priority_score
confidence_score
payload
description
```

## 38.3 Si la tensión fue cerrada

En MVP:

```text
No reabrir automáticamente.
Crear nueva tensión solo si corresponde a nuevo período.
```

En versiones posteriores:

```text
permitir política de reincidencia.
```

---

# 39. Política de errores

| Error                     | Acción                             |
| ------------------------- | ---------------------------------- |
| Regla inválida en DB      | Marcar evaluación failed           |
| KPI faltante              | Respetar missing_data_policy       |
| Confianza baja            | No disparar o warning              |
| Responsable no encontrado | Asignar fallback a gerente general |
| Acción desconocida        | Registrar warning y continuar      |
| Error DB                  | Rollback de transacción            |
| RLS bloquea operación     | Fallar y registrar error           |
| Duplicado                 | Actualizar tensión existente       |

---

# 40. Auditoría mínima

Cada corrida debe registrar:

```text
company_id
period_start
period_end
rules_evaluated
rules_triggered
tensions_created
actions_created
errors
warnings
started_at
finished_at
```

En MVP puede registrarse en `audit.audit_log`.

## `src/db/auditRepository.ts`

```ts
import type pg from "pg";

export async function insertAuditLog(
  client: pg.PoolClient,
  params: {
    companyId: string;
    actorUserId: string | null;
    entityName: string;
    action: string;
    metadata: Record<string, unknown>;
  }
): Promise<void> {
  await client.query(
    `
    INSERT INTO audit.audit_log (
      company_id,
      actor_user_id,
      entity_name,
      action,
      metadata
    )
    VALUES ($1, $2, $3, $4, $5::jsonb)
    `,
    [
      params.companyId,
      params.actorUserId,
      params.entityName,
      params.action,
      JSON.stringify(params.metadata)
    ]
  );
}
```

---

# 41. Notificaciones MVP

En MVP, notificaciones pueden quedar opcionales.

## Cuándo notificar

| Caso                     | Notifica          |
| ------------------------ | ----------------- |
| Tensión critical nueva   | Sí                |
| Acción critical asignada | Sí                |
| Acción vencida           | Sí                |
| Tensión medium           | No necesariamente |
| Dry-run                  | No                |

## Payload recomendado

```json
{
  "type": "critical_tension",
  "tension_code": "TNS-001",
  "title": "Crecimiento no rentable",
  "severity": "critical",
  "responsible_user_id": "...",
  "period_start": "2026-05-01",
  "period_end": "2026-05-31"
}
```

---

# 42. Tests unitarios mínimos

## `tests/evaluator.test.ts`

```ts
import { describe, expect, it } from "vitest";
import { evaluateConditionGroup } from "../src/engine/conditionEvaluator.js";
import { evaluateRule } from "../src/engine/evaluator.js";

describe("FARO evaluator", () => {
  it("evaluates all conditions as true", () => {
    const result = evaluateConditionGroup(
      {
        all: [
          {
            kpi: "KPI-SAL-001",
            metric: "delta_pct",
            operator: ">=",
            value: 0.1
          }
        ]
      },
      {
        "KPI-SAL-001": {
          kpiSnapshotId: "1",
          companyId: "company",
          kpiCode: "KPI-SAL-001",
          periodStart: "2026-05-01",
          periodEnd: "2026-05-31",
          dimensionType: "company",
          dimensionId: null,
          value: 100,
          referenceValue: 80,
          deltaValue: 20,
          deltaPct: 0.25,
          status: "ok",
          confidenceScore: 90,
          sourceSnapshot: {},
          calculatedAt: new Date().toISOString()
        }
      }
    );

    expect(result.passed).toBe(true);
  });

  it("does not trigger rule when KPI is missing and policy is do_not_trigger", () => {
    const rule: any = {
      ruleId: "rule-1",
      ruleCode: "RULE-TNS-001",
      severityDefault: "high",
      ruleBody: {
        tension_code: "TNS-001",
        data_requirements: {
          required_kpis: ["KPI-SAL-001"],
          minimum_confidence_score: 75,
          missing_data_policy: "do_not_trigger",
          stale_data_policy: "warn"
        },
        conditions: {
          all: [
            {
              kpi: "KPI-SAL-001",
              metric: "delta_pct",
              operator: ">=",
              value: 0.1
            }
          ]
        },
        severity: {
          default: "high"
        },
        output: {
          score_impact: {
            base: -8
          }
        }
      }
    };

    const result = evaluateRule({
      rule,
      snapshots: {}
    });

    expect(result.triggered).toBe(false);
    expect(result.missingKpis).toEqual(["KPI-SAL-001"]);
  });
});
```

---

# 43. Tests de integración mínimos

Estos requieren base local.

## 43.1 Test con Empresa Demo

```bash
npm run evaluate -- \
  --company-id 10000000-0000-0000-0000-000000000001 \
  --period-start 2026-05-01 \
  --period-end 2026-05-31
```

Debe crear o actualizar:

| Entidad          | Resultado |
| ---------------- | --------- |
| rule_evaluations | > 0       |
| tensions         | >= 1      |
| actions          | >= 1      |
| errores          | 0         |

## 43.2 Test de RLS

Correr con empresa inexistente o sin contexto debe fallar o devolver cero datos.

## 43.3 Test de idempotencia

Correr dos veces.

Esperado:

| Entidad           | Primera corrida |      Segunda corrida |
| ----------------- | --------------: | -------------------: |
| rule_evaluations  |              +N |                   +N |
| tensions abiertas |              +N | 0 nuevas / actualiza |
| actions abiertas  |              +N |             0 nuevas |

---

# 44. Métricas operativas del motor

El motor debe reportar:

| Métrica                        | Uso                                |
| ------------------------------ | ---------------------------------- |
| `rules_evaluated`              | Cantidad de reglas evaluadas       |
| `rules_triggered`              | Cantidad que dispararon            |
| `rules_skipped_missing_data`   | Reglas omitidas por falta de datos |
| `rules_skipped_low_confidence` | Reglas omitidas por baja confianza |
| `tensions_created`             | Tensiones nuevas                   |
| `tensions_updated`             | Tensiones actualizadas             |
| `actions_created`              | Acciones nuevas                    |
| `duration_ms`                  | Tiempo de corrida                  |
| `errors`                       | Errores                            |
| `warnings`                     | Advertencias                       |

---

# 45. Performance MVP

Para MVP, no optimizar prematuramente.

Pero sí respetar:

| Requisito      |               Meta inicial |
| -------------- | -------------------------: |
| 30 reglas      |               < 5 segundos |
| 300 reglas     |              < 30 segundos |
| 1 empresa demo |                  inmediato |
| 10 empresas    | batch secuencial aceptable |
| Idempotencia   |                obligatoria |
| Transacciones  |               obligatorias |

Más adelante:

* colas con BullMQ/Celery/RQ;
* procesamiento por compañía;
* procesamiento por período;
* locks por corrida;
* ejecución incremental;
* materialized views;
* cache de KPIs.

---

# 46. Locks para evitar corridas simultáneas

En MVP se puede usar advisory lock.

## SQL conceptual

```sql
SELECT pg_try_advisory_lock(hashtext('faro_evaluation:' || $1 || ':' || $2 || ':' || $3));
```

Si devuelve false:

```text
Ya hay una evaluación corriendo para esa empresa/período.
```

Esto evita duplicaciones por doble job.

---

# 47. Política de IA

El motor evaluador no usa IA para decidir.

Permitido:

```text
rule_evaluation.output_payload
→ IA redacta explicación ejecutiva
```

Prohibido:

```text
IA decide si se dispara una tensión
IA inventa KPI faltante
IA modifica severidad sin regla
IA cierra acción automáticamente
```

FARO calcula y gobierna.
La IA redacta, explica y asiste.

---

# 48. Criterios de aceptación de FARO-ENG-003

FARO-ENG-003 se considera aceptado si:

| Criterio                       | Estado esperado |
| ------------------------------ | --------------- |
| Lee reglas activas desde DB    | Sí              |
| Lee KPIs del período           | Sí              |
| Respeta `company_id` y RLS     | Sí              |
| Evalúa `all`, `any`, `none`    | Sí              |
| Valida datos faltantes         | Sí              |
| Valida confianza mínima        | Sí              |
| Calcula severidad              | Sí              |
| Calcula prioridad              | Sí              |
| Calcula impacto Score          | Sí              |
| Inserta `rule_evaluations`     | Sí              |
| Crea tensiones                 | Sí              |
| Actualiza tensiones existentes | Sí              |
| No duplica tensiones abiertas  | Sí              |
| Crea acciones sugeridas        | Sí              |
| Define evidencia requerida     | Sí              |
| Soporta dry-run                | Sí              |
| Soporta explain                | Sí              |
| Registra errores               | Sí              |
| Tiene tests unitarios          | Sí              |
| Tiene test de idempotencia     | Sí              |
| Corre con Empresa Demo         | Sí              |

---

# 49. Qué queda fuera del MVP

| Elemento                            | Motivo                        |
| ----------------------------------- | ----------------------------- |
| Evaluación en tiempo real           | Batch semanal/diario alcanza  |
| Recalibración automática            | Requiere histórico            |
| Machine learning                    | No necesario para MVP         |
| Simulación what-if                  | Fase posterior                |
| Peer comparison                     | Requiere múltiples clientes   |
| IA decisora                         | No corresponde                |
| Workflows complejos                 | Se inicia con estados simples |
| Branch/Area RLS avanzado            | Puede ir después              |
| Catálogo completo de 1000 tensiones | Primero 30 MVP                |

---

# 50. Roadmap inmediato después del motor

## Paso 1 · FARO-TEST-002

Tests completos para reglas:

```text
regla válida
regla inválida
caso dispara
caso no dispara
caso baja confianza
caso dato faltante
caso idempotencia
```

## Paso 2 · FARO-UI-001

Bandeja de tensiones:

```text
tensiones activas
prioridad
responsable
acción sugerida
evidencia requerida
estado
```

## Paso 3 · FARO-TPL-001

Emails de alerta:

```text
tensión crítica
acción asignada
acción vencida
evidencia requerida
```

## Paso 4 · FARO-TPL-002

Reporte semanal ejecutivo:

```text
Score
top tensiones
acciones
evidencias
foco semanal
```

---

# 51. Frase ejecutiva

FARO-ENG-003 es el punto donde FARO Connect deja de ser arquitectura y empieza a operar.

```text
Antes:
teníamos datos, reglas y tablas.

Ahora:
el sistema puede detectar tensiones,
crear acciones,
asignar responsables,
pedir evidencia
y preparar impacto sobre el Score.
```

Ese es el primer paso real hacia un sistema de dirección ejecutiva accionable.
