|
| 1 | +// EdgeVision baseline promotion seam — DISABLED BY DEFAULT, hard policy boundary. |
| 2 | +// |
| 3 | +// "Promoting a validated dataset to baseline" means: an admin acknowledges a |
| 4 | +// QA-validated intake batch as the evidence foundation for ONE planned |
| 5 | +// capability. It records provenance only. It never enables predictive output: |
| 6 | +// the Grade A boundary (`predictiveBaselineAvailable: false` in |
| 7 | +// api/_operational-intelligence.ts) stays pinned until Pescamar executes |
| 8 | +// Gate 5 and Gate 7 of docs/SEAFOOD-GRADE-A.md with real sign-off. |
| 9 | +// |
| 10 | +// This module is the single code seam for that future step. The only caller |
| 11 | +// is the admin-gated api/edgevision-baseline.ts endpoint, which stays inert |
| 12 | +// (409 with the policy explanation) until a validated batch meeting the |
| 13 | +// evidence thresholds below exists for the requested capability. |
| 14 | + |
| 15 | +import { getSql } from "./_db.js"; |
| 16 | + |
| 17 | +export const EDGE_VISION_BASELINE_CAPABILITIES = ["count", "calibre", "size", "defects", "biomass", "anomaly"] as const; |
| 18 | +export type EdgeVisionBaselineCapability = (typeof EDGE_VISION_BASELINE_CAPABILITIES)[number]; |
| 19 | + |
| 20 | +// Evidence thresholds grounded in the vision QA docs: real captures only, |
| 21 | +// human-confirmed labels, multiple capture days. Numbers are minimum intake |
| 22 | +// gates for CONSIDERING a baseline — never an accuracy claim. |
| 23 | +export type BaselineEvidenceRequirement = { |
| 24 | + capability: EdgeVisionBaselineCapability; |
| 25 | + minimumImages: number; |
| 26 | + minimumOperatorConfirmedLabels: number; |
| 27 | + minimumCaptureDays: number; |
| 28 | + rationale: string; |
| 29 | +}; |
| 30 | + |
| 31 | +export const baselineEvidenceRequirements: readonly BaselineEvidenceRequirement[] = [ |
| 32 | + { capability: "count", minimumImages: 300, minimumOperatorConfirmedLabels: 100, minimumCaptureDays: 3, rationale: "Conteo requiere suficientes ventanas de proceso con conteo humano de referencia para estimar error por escena." }, |
| 33 | + { capability: "calibre", minimumImages: 400, minimumOperatorConfirmedLabels: 150, minimumCaptureDays: 3, rationale: "Calibre exige cobertura por clase de calibre con etiqueta confirmada por operador, no sólo distribución agregada." }, |
| 34 | + { capability: "size", minimumImages: 300, minimumOperatorConfirmedLabels: 100, minimumCaptureDays: 3, rationale: "Tamaño dimensional necesita referencias de escala en escena y confirmación humana de las medidas usadas." }, |
| 35 | + { capability: "defects", minimumImages: 500, minimumOperatorConfirmedLabels: 200, minimumCaptureDays: 5, rationale: "Defectos necesita instancias positivas de cada taxonomía aprobada; la ausencia de defecto nunca se toma como aprobación." }, |
| 36 | + { capability: "biomass", minimumImages: 200, minimumOperatorConfirmedLabels: 80, minimumCaptureDays: 3, rationale: "Biomasa sólo es estimable con peso de referencia observado en la misma ventana de captura." }, |
| 37 | + { capability: "anomaly", minimumImages: 400, minimumOperatorConfirmedLabels: 120, minimumCaptureDays: 5, rationale: "Anomalías requiere tanto condición nominal como desviaciones reales documentadas antes de automatizar alertas." }, |
| 38 | +] as const; |
| 39 | + |
| 40 | +export const POLICY_BOUNDARY_MESSAGE = |
| 41 | + "La frontera predictiva permanece cerrada: promover un dataset a línea de base sólo registra evidencia. Las capacidades predictivas se habilitan únicamente después de Gate 5 (baselines contrastados con muestra real acordada) y Gate 7 (sign-off PASS de Pescamar) documentados en docs/SEAFOOD-GRADE-A.md. Hasta entonces predictiveBaselineAvailable sigue siendo false."; |
| 42 | + |
| 43 | +export type BaselinePromotionResult = |
| 44 | + | { promoted: false; code: "NO_VALIDATED_BATCH" | "INSUFFICIENT_EVIDENCE" | "ALREADY_PROMOTED"; reason: string } |
| 45 | + | { promoted: true; batchId: number; capability: EdgeVisionBaselineCapability; recordedAt: string }; |
| 46 | + |
| 47 | +type BatchRow = { |
| 48 | + id: number; |
| 49 | + plant_id: string; |
| 50 | + capability: EdgeVisionBaselineCapability; |
| 51 | + captured_from: string; |
| 52 | + captured_to: string; |
| 53 | + image_count: number; |
| 54 | + operator_confirmed_labels: number; |
| 55 | + qa_status: string; |
| 56 | + promoted_at: string | null; |
| 57 | +}; |
| 58 | + |
| 59 | +function captureDays(row: BatchRow): number { |
| 60 | + const from = new Date(row.captured_from).getTime(); |
| 61 | + const to = new Date(row.captured_to).getTime(); |
| 62 | + if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) return 0; |
| 63 | + return Math.floor((to - from) / 86_400_000) + 1; |
| 64 | +} |
| 65 | + |
| 66 | +export function assessBaselineEvidence(row: BatchRow): { ok: boolean; gaps: string[] } { |
| 67 | + const requirement = baselineEvidenceRequirements.find((item) => item.capability === row.capability); |
| 68 | + if (!requirement) return { ok: false, gaps: ["capacidad sin requisito de línea de base definido"] }; |
| 69 | + const gaps: string[] = []; |
| 70 | + if (row.qa_status !== "validated") gaps.push("el lote no está qa_status=validated"); |
| 71 | + if (row.image_count < requirement.minimumImages) gaps.push(`image_count ${row.image_count} < ${requirement.minimumImages}`); |
| 72 | + if (row.operator_confirmed_labels < requirement.minimumOperatorConfirmedLabels) gaps.push(`operator_confirmed_labels ${row.operator_confirmed_labels} < ${requirement.minimumOperatorConfirmedLabels}`); |
| 73 | + const days = captureDays(row); |
| 74 | + if (days < requirement.minimumCaptureDays) gaps.push(`ventana de captura ${days} día(s) < ${requirement.minimumCaptureDays}`); |
| 75 | + return { ok: gaps.length === 0, gaps }; |
| 76 | +} |
| 77 | + |
| 78 | +// Promotion is a provenance record on the batch row (promoted_at/promoted_by). |
| 79 | +// It is the ONLY write this seam performs and it activates no model, no |
| 80 | +// forecast and no automated decision. |
| 81 | +export async function promoteCapabilityBaseline(capability: EdgeVisionBaselineCapability, requestedBy: string): Promise<BaselinePromotionResult> { |
| 82 | + const rows = await getSql()` |
| 83 | + select id, plant_id, capability, captured_from, captured_to, image_count, operator_confirmed_labels, qa_status, promoted_at |
| 84 | + from edgevision_dataset_batches |
| 85 | + where capability=${capability} |
| 86 | + order by id desc |
| 87 | + limit 20`; |
| 88 | + const batches = (Array.isArray(rows) ? rows : []) as BatchRow[]; |
| 89 | + const candidates = batches.filter((row) => row.qa_status === "validated"); |
| 90 | + if (!candidates.length) |
| 91 | + return { promoted: false, code: "NO_VALIDATED_BATCH", reason: `No existe ningún lote qa_status=validated para la capacidad '${capability}'. ${POLICY_BOUNDARY_MESSAGE}` }; |
| 92 | + const eligible = candidates.filter((row) => assessBaselineEvidence(row).ok); |
| 93 | + if (!eligible.length) { |
| 94 | + const gaps = candidates.map((row) => `lote #${row.id}: ${assessBaselineEvidence(row).gaps.join("; ")}`).join(" | "); |
| 95 | + return { promoted: false, code: "INSUFFICIENT_EVIDENCE", reason: `Ningún lote validado cumple los umbrales de evidencia para '${capability}'. ${gaps}. ${POLICY_BOUNDARY_MESSAGE}` }; |
| 96 | + } |
| 97 | + const target = eligible[0]; |
| 98 | + if (target.promoted_at) |
| 99 | + return { promoted: false, code: "ALREADY_PROMOTED", reason: `El lote #${target.id} ya fue promovido como línea de base de '${capability}' el ${target.promoted_at}. ${POLICY_BOUNDARY_MESSAGE}` }; |
| 100 | + const updated = await getSql()` |
| 101 | + update edgevision_dataset_batches |
| 102 | + set promoted_at=now(), promoted_by=${requestedBy}::uuid |
| 103 | + where id=${target.id} and promoted_at is null |
| 104 | + returning id, promoted_at`; |
| 105 | + const row = Array.isArray(updated) ? (updated[0] as { id: number; promoted_at: string } | undefined) : undefined; |
| 106 | + if (!row) return { promoted: false, code: "ALREADY_PROMOTED", reason: `El lote #${target.id} ya fue promovido por otra sesión. ${POLICY_BOUNDARY_MESSAGE}` }; |
| 107 | + return { promoted: true, batchId: row.id, capability, recordedAt: row.promoted_at }; |
| 108 | +} |
0 commit comments