import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; import YAML from "yaml"; import { coverageKey, coverageSectorSchema, type CoverageMember, type CoverageSector } from "@websensor/core"; import { db, sql } from "@websensor/db"; import { cached } from "./cache"; /** * Global Observation Coverage Score — "how much of the world's high-value public web do we observe?" * * For every sector the universes in `config/coverage/*.yaml` are the denominator. A member is *covered* when at * least one active (non-shadow) sensor lives on its registrable domain (or on a host declared in `hints.hosts`). * breadth = Σ w·covered / Σ w (w = member importance 1–3) * depth = Σ w·min(1, sensors/5) / Σ w (five sensors per organization = full depth) * score = 100 · (0.7·breadth + 0.3·depth) * The global score is the sector-weight-weighted mean. Everything is explainable: the per-member table shows * which sensors count, what is in shadow and what the Factory is still queuing. */ export interface MemberCoverage { name: string; domain: string; country: string | null; importance: number; universe: string; covered: boolean; sensors: number; shadow: number; source_id: string | null; seed_status: string | null; depth: number; } export interface UniverseCoverage { key: string; label: string; provenance: string | null; members: number; covered: number; sensors: number; breadth: number; depth: number; score: number; } export interface SectorCoverage { sector: string; label: string; description: string | null; weight: number; members: number; covered: number; shadow_members: number; queued: number; sensors: number; breadth: number; depth: number; score: number; universes: UniverseCoverage[]; } const COVERAGE_DIR = process.env.WS_COVERAGE_DIR ?? "./config/coverage"; let fileCache: { at: number; sectors: CoverageSector[]; issues: string[] } | null = null; export function loadSectors(): { sectors: CoverageSector[]; issues: string[] } { if (fileCache && Date.now() - fileCache.at < 60_000) return fileCache; const byKey = new Map(); const issues: string[] = []; if (existsSync(COVERAGE_DIR) && statSync(COVERAGE_DIR).isDirectory()) { for (const f of readdirSync(COVERAGE_DIR).filter((x) => /\.ya?ml$/.test(x) && !x.startsWith("_")).sort()) { try { const parsed = coverageSectorSchema.safeParse(YAML.parse(readFileSync(join(COVERAGE_DIR, f), "utf8"))); if (!parsed.success) { issues.push(`${f}: ${parsed.error.issues[0]?.path.join(".")}: ${parsed.error.issues[0]?.message}`); continue; } const s = parsed.data; const prev = byKey.get(s.sector); if (prev) prev.universes.push(...s.universes); else byKey.set(s.sector, { ...s, universes: [...s.universes] }); } catch (e) { issues.push(`${f}: ${(e as Error).message}`); } } } fileCache = { at: Date.now(), sectors: [...byKey.values()], issues }; return fileCache; } interface Observed { /** registrable domain → { sensors (active), shadow, sourceId } */ byKey: Map; seeds: Map; } async function observed(): Promise { const [hosts, srcs, seeds] = await Promise.all([ db.execute<{ host: string; status: string; n: string; source_id: string }>(sql` select lower(split_part(split_part(url, '/', 3), ':', 1)) as host, case when status = 'SHADOW' then 'SHADOW' else 'ACTIVE' end as status, count(*)::text as n, min(source_id) as source_id from sensors where enabled and url like 'http%' group by 1, 2`).then((r) => r.rows), db.execute<{ id: string; domain: string }>(sql`select id, domain from sources where enabled and kind = 'registry'`).then((r) => r.rows), db.execute<{ domain: string; status: string }>(sql`select domain, status from factory_seeds`).then((r) => r.rows), ]); const byKey = new Map(); for (const h of hosts) { if (!h.host) continue; const k = coverageKey(h.host); const cur = byKey.get(k) ?? { sensors: 0, shadow: 0, sourceId: null }; if (h.status === "SHADOW") cur.shadow += Number(h.n); else cur.sensors += Number(h.n); cur.sourceId = cur.sourceId ?? h.source_id; byKey.set(k, cur); } for (const s of srcs) { const k = coverageKey(s.domain); const cur = byKey.get(k) ?? { sensors: 0, shadow: 0, sourceId: null }; cur.sourceId = cur.sourceId ?? s.id; byKey.set(k, cur); } const seedMap = new Map(); for (const s of seeds) seedMap.set(coverageKey(s.domain), s.status); return { byKey, seeds: seedMap }; } function memberCoverage(m: CoverageMember, universe: string, obs: Observed): MemberCoverage { const keys = [coverageKey(m.domain), ...((m.hints?.hosts ?? []).map((h) => coverageKey(h)))]; let sensors = 0; let shadow = 0; let sourceId: string | null = null; for (const k of new Set(keys)) { const o = obs.byKey.get(k); if (!o) continue; sensors += o.sensors; shadow += o.shadow; sourceId = sourceId ?? o.sourceId; } return { name: m.name, domain: m.domain, country: m.country ?? null, importance: m.importance, universe, covered: sensors > 0, sensors, shadow, source_id: sourceId, seed_status: obs.seeds.get(coverageKey(m.domain)) ?? null, depth: Math.min(1, sensors / 5) }; } function aggregate(members: MemberCoverage[]): { breadth: number; depth: number; score: number; covered: number; sensors: number } { let w = 0; let cov = 0; let dep = 0; let covered = 0; let sensors = 0; for (const m of members) { w += m.importance; if (m.covered) { cov += m.importance; covered++; } dep += m.importance * m.depth; sensors += m.sensors; } const breadth = w ? cov / w : 0; const depth = w ? dep / w : 0; return { breadth: round(breadth), depth: round(depth), score: round(100 * (0.7 * breadth + 0.3 * depth), 1), covered, sensors }; } const round = (x: number, d = 3): number => Math.round(x * 10 ** d) / 10 ** d; export async function coverageSummary(): Promise> { return cached("coverage:summary", 5 * 60_000, async () => { const { sectors, issues } = loadSectors(); const obs = await observed(); const out: SectorCoverage[] = []; let wsum = 0; let ssum = 0; let members = 0; let covered = 0; let sensorsTotal = 0; for (const s of sectors) { const all: MemberCoverage[] = []; const universes: UniverseCoverage[] = []; const seenDomains = new Set(); for (const u of s.universes) { const ms = u.members.map((m) => memberCoverage(m, u.key, obs)); const a = aggregate(ms); universes.push({ key: u.key, label: u.label, provenance: u.provenance ?? null, members: ms.length, covered: a.covered, sensors: a.sensors, breadth: a.breadth, depth: a.depth, score: a.score }); for (const m of ms) { const k = coverageKey(m.domain); if (seenDomains.has(k)) continue; seenDomains.add(k); all.push(m); } } const a = aggregate(all); const sec: SectorCoverage = { sector: s.sector, label: s.label, description: s.description ?? null, weight: s.weight, members: all.length, covered: a.covered, shadow_members: all.filter((m) => !m.covered && m.shadow > 0).length, queued: all.filter((m) => !m.covered && (m.seed_status === "queued" || m.seed_status === "discovering")).length, sensors: a.sensors, breadth: a.breadth, depth: a.depth, score: a.score, universes }; out.push(sec); wsum += s.weight; ssum += s.weight * a.score; members += all.length; covered += a.covered; sensorsTotal += a.sensors; } out.sort((a, b) => b.score - a.score); const [monitored, factory] = await Promise.all([ db.execute<{ sources: string; sensors: string; shadow: string; countries: string }>(sql`select (select count(*) from sources where enabled and kind = 'registry')::text as sources, (select count(*) from sensors where enabled and status <> 'SHADOW')::text as sensors, (select count(*) from sensors where enabled and status = 'SHADOW')::text as shadow, (select count(distinct country) from sources where country is not null and enabled)::text as countries`).then((r) => r.rows[0]), db.execute<{ status: string; n: string }>(sql`select status, count(*)::text as n from factory_seeds group by status`).then((r) => Object.fromEntries(r.rows.map((x) => [x.status, Number(x.n)]))), ]); return { global_score: wsum ? round(ssum / wsum, 1) : 0, members, covered, sensors_matched: sensorsTotal, sectors: out.map(({ universes: _u, ...rest }) => rest), monitored: { sources: Number(monitored?.sources ?? 0), sensors: Number(monitored?.sensors ?? 0), shadow: Number(monitored?.shadow ?? 0), countries: Number(monitored?.countries ?? 0) }, factory: factory, method: "breadth = importance-weighted share of members with ≥ 1 active sensor · depth = importance-weighted min(1, sensors/5) · score = 100·(0.7·breadth + 0.3·depth) · global = sector-weight-weighted mean", issues, generated_at: new Date().toISOString(), }; }); } export async function coverageSector(sector: string): Promise | null> { return cached(`coverage:sector:${sector}`, 5 * 60_000, async () => { const { sectors } = loadSectors(); const s = sectors.find((x) => x.sector === sector); if (!s) return null; const obs = await observed(); const universes = s.universes.map((u) => { const members = u.members.map((m) => memberCoverage(m, u.key, obs)).sort((a, b) => Number(b.covered) - Number(a.covered) || b.importance - a.importance || b.sensors - a.sensors || a.name.localeCompare(b.name)); const a = aggregate(members); return { key: u.key, label: u.label, description: u.description ?? null, provenance: u.provenance ?? null, members: members.length, covered: a.covered, sensors: a.sensors, breadth: a.breadth, depth: a.depth, score: a.score, items: members }; }); const flat = new Map(); for (const u of universes) for (const m of u.items) if (!flat.has(coverageKey(m.domain))) flat.set(coverageKey(m.domain), m); const a = aggregate([...flat.values()]); const byCountry = new Map(); for (const m of flat.values()) { const c = m.country ?? "—"; const cur = byCountry.get(c) ?? { members: 0, covered: 0 }; cur.members++; if (m.covered) cur.covered++; byCountry.set(c, cur); } return { sector: s.sector, label: s.label, description: s.description ?? null, weight: s.weight, categories: s.categories, members: flat.size, covered: a.covered, sensors: a.sensors, breadth: a.breadth, depth: a.depth, score: a.score, shadow_members: [...flat.values()].filter((m) => !m.covered && m.shadow > 0).length, queued: [...flat.values()].filter((m) => !m.covered && (m.seed_status === "queued" || m.seed_status === "discovering")).length, universes, by_country: [...byCountry.entries()].map(([country, v]) => ({ country, ...v })).sort((x, y) => y.members - x.members), generated_at: new Date().toISOString() }; }); }