import { NOISE_CLASSES } from "@websensor/core"; import { db, sql } from "@websensor/db"; import { factoryConfig, log } from "../config"; import { bumpFactoryDaily } from "./run"; /** * Shadow evaluation — the Factory's "signal/noise evaluation" stage. A shadow sensor is polled like any other * sensor (evidence is stored) but never publishes. Once it has enough checks (or enough time has passed) it is: * rejected — errors (≥ 50 % failed checks, or 3+ consecutive errors), pure noise (changes on most checks, * none of them meaningful), or a duplicate (same latest canonical content as an active sensor of * the same source); * accepted — otherwise, becoming a production sensor with its real priority; * deferred — not enough evidence yet (until `shadowMaxHours`, then accepted if it never failed). */ export interface ShadowDecision { sensorId: string; decision: "accepted" | "rejected" | "deferred"; reason: string; } interface ShadowRow extends Record { id: string; source_id: string; url: string; tier: string; config: Record; total_runs: number; consecutive_errors: number; raw_changes: number; health: string; last_snapshot_id: string | null; shadow_since: string | null; categories: string[]; runs: number; failed: number; changes: number; noise_changes: number; last_canonical: string | null; dup_sensor: string | null; } export async function evaluateShadows(opts: { limit?: number; force?: boolean } = {}): Promise<{ accepted: number; rejected: number; deferred: number; decisions: ShadowDecision[] }> { const noise = [...NOISE_CLASSES]; const rows = await db.execute(sql` select s.id, s.source_id, s.url, s.tier, s.config, s.total_runs, s.consecutive_errors, s.raw_changes, s.health, s.last_snapshot_id, s.config->>'shadowSince' as shadow_since, so.categories, (select count(*)::int from sensor_runs r where r.sensor_id = s.id) as runs, (select count(*)::int from sensor_runs r where r.sensor_id = s.id and r.outcome in ('error','parse_error','rate_limited','missing')) as failed, (select count(*)::int from changes c where c.sensor_id = s.id) as changes, (select count(*)::int from changes c where c.sensor_id = s.id and c.change_class = any(${sql.raw("array[" + noise.map((n) => "'" + n + "'").join(",") + "]::text[]")})) as noise_changes, (select sn.canonical_hash from snapshots sn where sn.id = s.last_snapshot_id) as last_canonical, (select x.id from sensors x join snapshots sx on sx.id = x.last_snapshot_id where x.source_id = s.source_id and x.id <> s.id and x.status <> 'SHADOW' and x.enabled and sx.canonical_hash = (select sn2.canonical_hash from snapshots sn2 where sn2.id = s.last_snapshot_id) limit 1) as dup_sensor from sensors s join sources so on so.id = s.source_id where s.status = 'SHADOW' and s.enabled order by s.total_runs desc limit ${opts.limit ?? 2000}`); const decisions: ShadowDecision[] = []; let accepted = 0; let rejected = 0; let deferred = 0; const now = Date.now(); for (const r of rows.rows) { const since = r.shadow_since ? new Date(r.shadow_since).getTime() : now; const hours = (now - since) / 3600e3; const runs = Math.max(r.runs, r.total_runs); let decision: ShadowDecision["decision"] = "deferred"; let reason = `${runs} checks · ${hours.toFixed(0)} h`; if (r.dup_sensor && runs >= 2) { decision = "rejected"; reason = `duplicate content of ${r.dup_sensor}`; } else if (r.consecutive_errors >= 3 || (runs >= 4 && r.failed / runs >= 0.5)) { decision = "rejected"; reason = `errors ${r.failed}/${runs} (consecutive ${r.consecutive_errors})`; } else if (r.changes >= 4 && r.noise_changes === r.changes && r.changes >= runs * 0.6) { decision = "rejected"; reason = `noise: ${r.changes} changes on ${runs} checks, all cosmetic/timestamp/ads`; } else if (opts.force || (runs >= factoryConfig.shadowMinChecks && hours >= factoryConfig.shadowMinHours) || (hours >= factoryConfig.shadowMaxHours && runs >= 2 && r.failed === 0)) { if (r.health !== "UP" && !opts.force) { decision = "deferred"; reason = `health ${r.health} — waiting`; } else { decision = "accepted"; reason = `${runs} checks · ${r.changes} changes (${r.changes - r.noise_changes} meaningful) · ${r.failed} errors · ${hours.toFixed(0)} h`; } } else if (hours >= factoryConfig.shadowMaxHours && runs < 2) { decision = "rejected"; reason = `never observed successfully in ${hours.toFixed(0)} h`; } if (decision === "accepted") { const targetPriority = Number((r.config as { targetPriority?: number }).targetPriority ?? 2); await db.execute(sql`update sensors set status = 'ACTIVE', priority = ${targetPriority}, config = (config - 'shadow') || jsonb_build_object('acceptedAt', ${new Date().toISOString()}::text, 'shadowReport', ${reason}::text), updated_at = now() where id = ${r.id}`); await db.execute(sql`update discovery_candidates set status = 'accepted', reason = ${reason}, decided_at = now(), updated_at = now() where shadow_sensor_id = ${r.id}`); await db.execute(sql`update factory_seeds set accepted = accepted + 1, updated_at = now() where id = ${(r.config as { seedId?: string }).seedId ?? ""}`); accepted++; } else if (decision === "rejected") { await db.execute(sql`update sensors set status = 'DISABLED', enabled = false, config = config || jsonb_build_object('rejectedAt', ${new Date().toISOString()}::text, 'shadowReport', ${reason}::text), updated_at = now() where id = ${r.id}`); await db.execute(sql`update discovery_candidates set status = 'rejected', reason = ${reason}, decided_at = now(), updated_at = now() where shadow_sensor_id = ${r.id}`); await db.execute(sql`update factory_seeds set rejected = rejected + 1, updated_at = now() where id = ${(r.config as { seedId?: string }).seedId ?? ""}`); rejected++; } else deferred++; decisions.push({ sensorId: r.id, decision, reason }); } if (accepted || rejected) { await bumpFactoryDaily({ accepted, rejected }); log.info({ accepted, rejected, deferred }, "factory: shadow evaluation"); } return { accepted, rejected, deferred, decisions }; }