TypeScript 55.4%
Python 43.2%
SQL 1.2%
1import { NOISE_CLASSES } from "@websensor/core";2import { db, sql } from "@websensor/db";3import { factoryConfig, log } from "../config";4import { bumpFactoryDaily } from "./run";56/**7 * Shadow evaluation — the Factory's "signal/noise evaluation" stage. A shadow sensor is polled like any other8 * sensor (evidence is stored) but never publishes. Once it has enough checks (or enough time has passed) it is:9 * rejected — errors (≥ 50 % failed checks, or 3+ consecutive errors), pure noise (changes on most checks,10 * none of them meaningful), or a duplicate (same latest canonical content as an active sensor of11 * the same source);12 * accepted — otherwise, becoming a production sensor with its real priority;13 * deferred — not enough evidence yet (until `shadowMaxHours`, then accepted if it never failed).14 */15export interface ShadowDecision {16 sensorId: string;17 decision: "accepted" | "rejected" | "deferred";18 reason: string;19}2021interface ShadowRow extends Record<string, unknown> {22 id: string;23 source_id: string;24 url: string;25 tier: string;26 config: Record<string, unknown>;27 total_runs: number;28 consecutive_errors: number;29 raw_changes: number;30 health: string;31 last_snapshot_id: string | null;32 shadow_since: string | null;33 categories: string[];34 runs: number;35 failed: number;36 changes: number;37 noise_changes: number;38 last_canonical: string | null;39 dup_sensor: string | null;40}4142export async function evaluateShadows(opts: { limit?: number; force?: boolean } = {}): Promise<{ accepted: number; rejected: number; deferred: number; decisions: ShadowDecision[] }> {43 const noise = [...NOISE_CLASSES];44 const rows = await db.execute<ShadowRow>(sql`45 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,46 s.config->>'shadowSince' as shadow_since, so.categories,47 (select count(*)::int from sensor_runs r where r.sensor_id = s.id) as runs,48 (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,49 (select count(*)::int from changes c where c.sensor_id = s.id) as changes,50 (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,51 (select sn.canonical_hash from snapshots sn where sn.id = s.last_snapshot_id) as last_canonical,52 (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_sensor53 from sensors s join sources so on so.id = s.source_id54 where s.status = 'SHADOW' and s.enabled55 order by s.total_runs desc56 limit ${opts.limit ?? 2000}`);57 const decisions: ShadowDecision[] = [];58 let accepted = 0;59 let rejected = 0;60 let deferred = 0;61 const now = Date.now();62 for (const r of rows.rows) {63 const since = r.shadow_since ? new Date(r.shadow_since).getTime() : now;64 const hours = (now - since) / 3600e3;65 const runs = Math.max(r.runs, r.total_runs);66 let decision: ShadowDecision["decision"] = "deferred";67 let reason = `${runs} checks · ${hours.toFixed(0)} h`;68 if (r.dup_sensor && runs >= 2) {69 decision = "rejected";70 reason = `duplicate content of ${r.dup_sensor}`;71 } else if (r.consecutive_errors >= 3 || (runs >= 4 && r.failed / runs >= 0.5)) {72 decision = "rejected";73 reason = `errors ${r.failed}/${runs} (consecutive ${r.consecutive_errors})`;74 } else if (r.changes >= 4 && r.noise_changes === r.changes && r.changes >= runs * 0.6) {75 decision = "rejected";76 reason = `noise: ${r.changes} changes on ${runs} checks, all cosmetic/timestamp/ads`;77 } else if (opts.force || (runs >= factoryConfig.shadowMinChecks && hours >= factoryConfig.shadowMinHours) || (hours >= factoryConfig.shadowMaxHours && runs >= 2 && r.failed === 0)) {78 if (r.health !== "UP" && !opts.force) {79 decision = "deferred";80 reason = `health ${r.health} — waiting`;81 } else {82 decision = "accepted";83 reason = `${runs} checks · ${r.changes} changes (${r.changes - r.noise_changes} meaningful) · ${r.failed} errors · ${hours.toFixed(0)} h`;84 }85 } else if (hours >= factoryConfig.shadowMaxHours && runs < 2) {86 decision = "rejected";87 reason = `never observed successfully in ${hours.toFixed(0)} h`;88 }8990 if (decision === "accepted") {91 const targetPriority = Number((r.config as { targetPriority?: number }).targetPriority ?? 2);92 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}`);93 await db.execute(sql`update discovery_candidates set status = 'accepted', reason = ${reason}, decided_at = now(), updated_at = now() where shadow_sensor_id = ${r.id}`);94 await db.execute(sql`update factory_seeds set accepted = accepted + 1, updated_at = now() where id = ${(r.config as { seedId?: string }).seedId ?? ""}`);95 accepted++;96 } else if (decision === "rejected") {97 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}`);98 await db.execute(sql`update discovery_candidates set status = 'rejected', reason = ${reason}, decided_at = now(), updated_at = now() where shadow_sensor_id = ${r.id}`);99 await db.execute(sql`update factory_seeds set rejected = rejected + 1, updated_at = now() where id = ${(r.config as { seedId?: string }).seedId ?? ""}`);100 rejected++;101 } else deferred++;102 decisions.push({ sensorId: r.id, decision, reason });103 }104 if (accepted || rejected) {105 await bumpFactoryDaily({ accepted, rejected });106 log.info({ accepted, rejected, deferred }, "factory: shadow evaluation");107 }108 return { accepted, rejected, deferred, decisions };109}110