SPB Git forge

spb/websensor

Public
33commits 1branches 0releases
3.4 MBsize
maindefault branch
10 days agolast push
TypeScript 55.4% Python 43.2% SQL 1.2%
6.2 KB · 110 lines typescript
Raw Blame History
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