/** * Claim-first data architecture — shared vocabulary and deterministic classifiers. * * A CLAIM is one figure or value asserted by one document about one subject, with a SCOPE (what the number describes: * a building, a facility, a campus, a metro, a country, a company portfolio…), the supporting sentence, the parser that * produced it and the field-level authority of its source. Reconciliation (apps/worker/src/ingest/claims.ts) decides * which claim becomes the displayed column value; nothing here touches the database. * * Rules of the house: * - a figure whose scope is not the subject's own scope (portfolio / company / country / metro / unknown) is stored * as a claim but NEVER written to the subject's capacity or investment column ("unscoped"); * - capacity semantics are kept apart: IT load ≠ utility power ≠ grid connection ≠ planned ≠ ultimate build-out; * - every numerical claim needs a supporting sentence — no sentence, no claim. */ import type { SourceKind } from "./types.js"; // ─── scopes ──────────────────────────────────────────────────────────────────────────────────────── export const CLAIM_SCOPES = ["building", "facility", "campus", "metro", "country", "portfolio", "company", "unknown"] as const; export type ClaimScope = (typeof CLAIM_SCOPES)[number]; /** Scopes that may populate a facility / campus / project record's own figures. */ export const SITE_SCOPES: ReadonlySet = new Set(["building", "facility", "campus"]); export function isSiteScope(s: ClaimScope | string | null | undefined): boolean { return !!s && SITE_SCOPES.has(s as ClaimScope); } // ─── predicates ──────────────────────────────────────────────────────────────────────────────────── export const CAPACITY_PREDICATES = ["it_capacity_mw", "critical_power_mw", "utility_capacity_mw", "grid_connection_mw", "current_power_mw", "planned_power_mw", "ultimate_campus_mw", "phase_mw"] as const; export type CapacityPredicate = (typeof CAPACITY_PREDICATES)[number]; export const INVESTMENT_PREDICATES = ["project_investment_usd", "campus_investment_usd", "company_investment_usd", "country_program_usd", "multi_year_capex_usd", "deal_value_usd"] as const; export type InvestmentPredicate = (typeof INVESTMENT_PREDICATES)[number]; export const CLAIM_STATUSES = ["current", "superseded", "rejected", "review", "unscoped"] as const; export type ClaimStatus = (typeof CLAIM_STATUSES)[number]; export const CAPACITY_PREDICATE_LABEL: Record = { it_capacity_mw: "IT capacity", critical_power_mw: "Critical power", utility_capacity_mw: "Utility capacity", grid_connection_mw: "Grid connection", current_power_mw: "Current power", planned_power_mw: "Planned capacity", ultimate_campus_mw: "Ultimate campus build-out", phase_mw: "Phase capacity", }; export const INVESTMENT_PREDICATE_LABEL: Record = { project_investment_usd: "Project investment", campus_investment_usd: "Campus investment", company_investment_usd: "Company investment", country_program_usd: "Country program", multi_year_capex_usd: "Multi-year capex", deal_value_usd: "Deal value", }; /** Which facility column a capacity predicate feeds (null = stored as a claim only). */ export const CAPACITY_COLUMN: Record = { it_capacity_mw: "itCapacityMw", critical_power_mw: "itCapacityMw", utility_capacity_mw: "utilityCapacityMw", grid_connection_mw: "gridConnectionMw", current_power_mw: "totalPowerMw", planned_power_mw: "plannedPowerMw", ultimate_campus_mw: "ultimateCampusMw", phase_mw: null, }; // ─── field-level source authority ────────────────────────────────────────────────────────────────── export type AuthorityTier = "A" | "B" | "C" | "D" | "E"; export const TIER_RANK: Record = { A: 5, B: 4, C: 3, D: 2, E: 1 }; export const AUTHORITY_FIELDS = ["capacity", "investment", "geometry", "interconnection", "status", "ownership", "dates", "identity", "planning"] as const; export type AuthorityField = (typeof AUTHORITY_FIELDS)[number]; /** * Field-level authority hierarchy. One universal rank is wrong: OpenStreetMap is excellent for geometry and useless for * capacity; PeeringDB (registry) is the reference for interconnection; planning filings beat press releases for approved * capacity; utility filings beat everything for grid requirements. */ export const FIELD_AUTHORITY: Record> = { capacity: { utility: "A", government: "A", filing: "A", operator: "B", cloud_provider: "B", registry: "C", secondary: "C", news: "D", dataset: "E", community: "E" }, investment: { filing: "A", government: "A", operator: "B", cloud_provider: "B", utility: "C", secondary: "C", news: "D", registry: "E", dataset: "E", community: "E" }, geometry: { government: "A", community: "A", registry: "B", operator: "B", dataset: "B", cloud_provider: "C", utility: "C", filing: "C", secondary: "D", news: "E" }, interconnection: { registry: "A", operator: "B", community: "C", dataset: "C", secondary: "D", news: "D", government: "D", filing: "D", utility: "E", cloud_provider: "E" }, status: { operator: "A", government: "A", filing: "A", cloud_provider: "A", utility: "B", registry: "B", secondary: "C", news: "C", dataset: "D", community: "D" }, ownership: { filing: "A", operator: "A", government: "B", registry: "B", cloud_provider: "B", secondary: "C", news: "C", dataset: "D", community: "D", utility: "D" }, dates: { operator: "A", government: "A", filing: "A", cloud_provider: "A", secondary: "B", news: "B", registry: "C", dataset: "C", utility: "C", community: "D" }, identity: { operator: "A", registry: "A", government: "B", filing: "B", cloud_provider: "B", dataset: "C", community: "C", secondary: "D", news: "D", utility: "D" }, planning: { government: "A", filing: "A", utility: "B", operator: "B", secondary: "C", news: "C", cloud_provider: "C", registry: "D", dataset: "E", community: "E" }, }; /** Field a predicate belongs to (for the authority lookup). */ export function authorityFieldFor(predicate: string): AuthorityField { if ((CAPACITY_PREDICATES as readonly string[]).includes(predicate) || /mw$/i.test(predicate)) return "capacity"; if ((INVESTMENT_PREDICATES as readonly string[]).includes(predicate) || /usd$|investment/i.test(predicate)) return "investment"; if (/^(geo|lat|lng|address|postal)/i.test(predicate)) return "geometry"; if (/^(carriers|ixps|cloudProviders|networks)/i.test(predicate)) return "interconnection"; if (/status/i.test(predicate)) return "status"; if (/(operator|owner|tenant|developer)/i.test(predicate)) return "ownership"; if (/(On|Opening|date)$/i.test(predicate) || /^(opened|announced|construction|expected)/i.test(predicate)) return "dates"; if (/permit|planning|approv/i.test(predicate)) return "planning"; return "identity"; } export interface AuthorityInput { field: AuthorityField; sourceKind: SourceKind | string | null | undefined; isEstimate?: boolean | null; /** extraction method: structured / parser / regex on prose / llm */ method?: string | null; reviewed?: boolean } /** Authority tier of one observation for one field. Estimates and LLM-only extractions drop a tier; a human review raises one. */ export function authorityTier(i: AuthorityInput): AuthorityTier { const table = FIELD_AUTHORITY[i.field]; let rank = TIER_RANK[(i.sourceKind && table[i.sourceKind as SourceKind]) || "D"]; if (i.isEstimate) rank -= 1; if (i.method && /^llm/i.test(i.method)) rank -= 1; if (i.reviewed) rank += 1; rank = Math.max(1, Math.min(5, rank)); return (Object.keys(TIER_RANK) as AuthorityTier[]).find((t) => TIER_RANK[t] === rank) ?? "E"; } // ─── scope classification ────────────────────────────────────────────────────────────────────────── const PORTFOLIO_RE = /\b(across (?:its|our|their|the company'?s?|a) (?:global |entire |whole |growing |existing )?(?:portfolio|footprint|platform|network|fleet|estate)|portfolio|footprint|pipeline|(?:contracted|committed|signed|operating|existing|total) (?:large[- ])?(?:data cent(?:er|re) )?load|large[- ]load agreements?|(?:agreements?|contracts?) total(?:l?ing)?|under management|under development globally|(?:global|total|combined|aggregate|cumulative) (?:capacity|footprint|pipeline|portfolio)|pipeline of|development pipeline|(?:brings?|bringing|takes?|taking|lifts?|lifting|grows?|growing) (?:its|the company'?s?|our|their|total) [^.]{0,60}(?:capacity |footprint |portfolio )?to (?:over |more than |nearly |approximately |about |roughly )?\d|now (?:operates|owns|manages|has) (?:over |more than |nearly )?\d|operates (?:over |more than |nearly )?\d+[\d.,]* ?(?:mw|gw|megawatts?|gigawatts?) (?:across|in|of)|(?:more than|over) \d+ (?:data cent(?:er|re)s|facilities|sites|campuses|locations)|globally|worldwide|company-?wide|enterprise-?wide|group-?wide)\b/i; const COMPANY_RE = /\b(capex|capital (?:expenditure|plan|program|programme|spending)|generation investments?|(?:potential |planned |total )?spending (?:exceeding|of|plan)|rate base|will invest [^.]{0,40}(?:over the next|through|by) (?:the next )?(?:\d+ years|20\d\d)|(?:through|by|over the next|over the coming) (?:20\d\d|\d+ years|the decade)|(?:total|planned|annual) investment (?:of|in) [^.]{0,30}(?:across|in) (?:the (?:us|uk|country|region)|[A-Z][a-z]+ and [A-Z][a-z]+)|(?:in|for) (?:its|our|their) (?:fiscal|financial) (?:year|quarter)|(?:fiscal|fy) ?20\d\d|guidance|earnings|revenue|quarterly results|annual report|10-k|market (?:to|will|set to|expected to|projected to) (?:surpass|reach|hit|grow|exceed|top)|market size|cagr|forecast period)\b/i; const COUNTRY_RE = /\b(nationwide|nation-?wide|national (?:program|programme|plan|strategy|investment|pipeline)|across the country|the country'?s|country-?wide|(?:in|across) (?:the )?(?:united states|u\.s\.|us|uk|united kingdom|europe|emea|apac|apj|asia|asia-pacific|latin america|the americas|the middle east|africa|india|japan|germany|france|australia|canada|brazil|malaysia|singapore|the netherlands|ireland|spain|italy|the nordics|scandinavia|the region)\b)/i; const METRO_RE = /\b((?:in|across) the (?:[A-Z][\w.]+ )?(?:market|metro|region|area|corridor|cluster)|(?:the )?(?:northern virginia|dfw|dallas-fort worth|silicon valley|greater [A-Z][a-z]+|the bay area) (?:market|region|area)|market'?s (?:total|inventory|capacity)|inventory|absorption|vacancy)\b/i; const CAMPUS_RE = /\b(campus|campuses|park|complex|estate|hub|gigafactory|(?:at|when|upon|once) (?:full(?:y)? )?(?:built[- ]out|build[- ]out|buildout|complete|completed|completion)|full build-?out|ultimate(?:ly)?|(?:total|overall|eventual) (?:site|campus|planned) capacity|master-?plan(?:ned)?|(?:across|over|in) (?:\w+ )?(?:phases|buildings|data halls)|multi-?building|multi-?phase|acre (?:site|campus|development)|the (?:site|development|project) (?:will|would|could) (?:deliver|provide|offer|support|house|host|total|reach)|entire (?:site|development|project))\b/i; const BUILDING_RE = /\b(building [A-Z0-9]+|(?:first|second|third|fourth|fifth|initial|new|final) (?:building|data hall|hall|structure|phase)|data hall|phase (?:\d+|one|two|three|i|ii|iii)\b|bldg\.?|the (?:building|hall) (?:will|would|is|has|offers|provides|delivers)|single[- ]storey|two-?stor(?:e)?y|three-?stor(?:e)?y|\d+[- ]stor(?:e)?y)\b/i; const FACILITY_RE = /\b(facility|the data cent(?:er|re)|this data cent(?:er|re)|the site|the new data cent(?:er|re)|the (?:\w+ )?data cent(?:er|re) (?:will|would|is|has|offers|provides|delivers))\b/i; export interface ScopeClassification { scope: ClaimScope; reason: string } /** * Deterministic scope of a figure from the sentence (and the few words) around it. Precedence: company → portfolio → * country → metro → campus → building → facility → unknown. `subjectHint` (what the record itself is) only breaks a tie * when the sentence says nothing. An UNKNOWN scope must never populate a facility column. */ export function classifyScope(context: string | null | undefined, subjectHint?: ClaimScope | null): ScopeClassification { const s = (context ?? "").replace(/\s+/g, " "); if (!s.trim()) return { scope: subjectHint ?? "unknown", reason: subjectHint ? "context:empty+subject" : "context:empty" }; if (COMPANY_RE.test(s)) return { scope: "company", reason: "regex:company" }; if (PORTFOLIO_RE.test(s)) return { scope: "portfolio", reason: "regex:portfolio" }; if (COUNTRY_RE.test(s)) return { scope: "country", reason: "regex:country" }; if (METRO_RE.test(s)) return { scope: "metro", reason: "regex:metro" }; if (CAMPUS_RE.test(s)) return { scope: "campus", reason: "regex:campus" }; if (BUILDING_RE.test(s)) return { scope: "building", reason: "regex:building" }; if (FACILITY_RE.test(s)) return { scope: "facility", reason: "regex:facility" }; if (subjectHint) return { scope: subjectHint, reason: "subject" }; return { scope: "unknown", reason: "no-signal" }; } // ─── capacity semantics ──────────────────────────────────────────────────────────────────────────── const IT_RE = /\b(it (?:load|capacity|power)|critical (?:it )?(?:load|capacity)|of it\b|it-?load|white ?space power|customer (?:power|load)|usable (?:power|capacity)|for it equipment|critical power)\b/i; const CRITICAL_RE = /\bcritical (?:it )?(?:load|power|capacity)\b/i; const GRID_RE = /\b(grid connection|grid-?connected|interconnection (?:agreement|capacity|request|queue)|connection (?:agreement|to the grid|capacity)|from the (?:grid|utility|substation)|substation|transmission (?:line|capacity|connection)|(?:secured|reserved|contracted|allocated|committed|approved) (?:\w+ )?(?:mw|gw|megawatts?|gigawatts?) of (?:grid |utility )?(?:power|electricity|capacity|supply|load)|load (?:request|letter|study|agreement)|large[- ]load|energi[sz]ation of)\b/i; const UTILITY_RE = /\b(utility (?:power|capacity|supply|feed|service)|power (?:supply|feed|service) (?:agreement|contract|capacity)|(?:power|electricity) (?:supply|allocation) (?:of|totaling|totalling)|(?:dual|redundant) (?:utility )?feeds?|incoming power|electrical (?:capacity|supply|service)|total (?:site )?power|power capacity of|available power)\b/i; const ULTIMATE_RE = /\b((?:at|when|upon|once) (?:full(?:y)? )?(?:built[- ]out|build[- ]out|buildout|complete|completed|completion)|full build-?out|ultimate(?:ly)?|final build-?out|(?:total|overall|eventual|potential|maximum) (?:site|campus|planned|development) (?:capacity|power|load)|(?:scale|scalable|scaling|grow|growing|expand|expandable) (?:up )?to (?:over |more than )?\d|up to (?:over |more than |approximately |nearly )?\d|master-?plan)\b/i; const PLANNED_RE = /\b(planned|proposed|will (?:deliver|provide|offer|have|house|support|feature|bring|add|total)|would (?:deliver|provide|offer|have|house|support)|expected to (?:deliver|provide|offer|reach|have)|is (?:set|slated|expected|designed) to|designed (?:for|to)|target(?:s|ed|ing)? (?:capacity|of)|to be (?:built|developed|delivered)|future|when complete|on completion|approved (?:for|capacity)|permitted|zoned for|application for|seeks? (?:approval|permission) for)\b/i; const PHASE_RE = /\b((?:first|second|third|fourth|initial|next|final) phase|phase (?:\d+|one|two|three|four|i|ii|iii|iv)\b|(?:first|initial) (?:building|data hall|tranche)|initial(?:ly)? (?:deliver|provide|offer)\w*)\b/i; const CURRENT_RE = /\b(currently|existing|in operation|operational|operating|live|online|now (?:offers|provides|delivers|has|houses)|today|present(?:ly)?|commissioned|energi[sz]ed|installed|delivered|is (?:offering|providing|delivering))\b/i; export interface CapacitySemantics { predicate: CapacityPredicate | null; reason: string } /** * Which kind of MW a sentence describes. Precedence: grid → utility → IT → phase → ultimate → planned → current. * Returns null when the sentence gives no semantic cue; the caller decides a default from the record status * (pipeline record → planned, operational record → total power) and flags the figure as "semantics: default". */ export function classifyCapacitySemantics(context: string | null | undefined): CapacitySemantics { const s = (context ?? "").replace(/\s+/g, " "); if (!s.trim()) return { predicate: null, reason: "context:empty" }; const isIt = IT_RE.test(s); if (GRID_RE.test(s) && !isIt) return { predicate: "grid_connection_mw", reason: "regex:grid" }; if (UTILITY_RE.test(s) && !isIt) return { predicate: "utility_capacity_mw", reason: "regex:utility" }; if (PHASE_RE.test(s) && !ULTIMATE_RE.test(s)) return { predicate: "phase_mw", reason: "regex:phase" }; if (ULTIMATE_RE.test(s)) return { predicate: "ultimate_campus_mw", reason: "regex:ultimate" }; if (isIt) return { predicate: CRITICAL_RE.test(s) ? "critical_power_mw" : "it_capacity_mw", reason: "regex:it" }; if (PLANNED_RE.test(s)) return { predicate: "planned_power_mw", reason: "regex:planned" }; if (CURRENT_RE.test(s)) return { predicate: "current_power_mw", reason: "regex:current" }; return { predicate: null, reason: "no-signal" }; } // ─── investment semantics ────────────────────────────────────────────────────────────────────────── const MULTI_YEAR_RE = /\b((?:through|by|over the next|over the coming|until|to) (?:20\d\d|\d+ years|the decade|the end of the decade|the mid-?20\d\ds)|multi-?year|generation investments?|capital (?:plan|program|programme)|(?:potential |planned )?spending exceeding|\d+-year (?:plan|investment|programme|program|commitment)|(?:annual|yearly) (?:capex|capital expenditure|investment)|capex|capital expenditure)\b/i; const COUNTRY_PROGRAM_RE = /\b((?:national|country|sovereign|government|federal|state) (?:program|programme|plan|initiative|strategy|fund|investment)|(?:invest|investment|commit\w*|pledge\w*) [^.]{0,60}(?:in|across) (?:the )?(?:united states|u\.s\.|us|uk|united kingdom|europe|india|japan|germany|france|australia|canada|brazil|malaysia|singapore|the country|the nation|the region|emea|apac)\b)/i; const DEAL_RE = /\b(acqui(?:re|res|red|sition)|takeover|buyout|merger|stake|valuation|valued at|purchase price|sale of|sells?|sold|buys?|bought|financing|refinanc\w+|loan|credit facility|bond|notes|debt|equity|raises?|raised|funding round|series [a-e]|lease(?:d|s)? (?:valued|worth)|contract (?:valued|worth))\b/i; const CAMPUS_INV_RE = /\b(campus|site|park|complex|development|project|phase|build-?out|the facility|the data cent(?:er|re))\b/i; export interface InvestmentSemantics { predicate: InvestmentPredicate; scope: ClaimScope; reason: string } /** Which kind of money a sentence describes and which scope it applies to. Deal values and company capex never become a project's investment. */ export function classifyInvestmentSemantics(context: string | null | undefined, subjectHint?: ClaimScope | null): InvestmentSemantics { const s = (context ?? "").replace(/\s+/g, " "); if (!s.trim()) return { predicate: "project_investment_usd", scope: subjectHint ?? "unknown", reason: "context:empty" }; if (DEAL_RE.test(s) && !/\b(invest(?:s|ed|ing|ment)? (?:of |about |approximately |over |more than |up to )?[$€£]|to build|construction|will invest|plans to invest)\b/i.test(s)) return { predicate: "deal_value_usd", scope: "company", reason: "regex:deal" }; if (COUNTRY_PROGRAM_RE.test(s)) return { predicate: "country_program_usd", scope: "country", reason: "regex:country-program" }; if (MULTI_YEAR_RE.test(s) || COMPANY_RE.test(s)) return { predicate: "multi_year_capex_usd", scope: "company", reason: "regex:multi-year" }; if (PORTFOLIO_RE.test(s)) return { predicate: "company_investment_usd", scope: "portfolio", reason: "regex:portfolio" }; const sc = classifyScope(s, subjectHint); if (sc.scope === "campus") return { predicate: "campus_investment_usd", scope: "campus", reason: sc.reason }; if (sc.scope === "building" || sc.scope === "facility") return { predicate: "project_investment_usd", scope: sc.scope, reason: sc.reason }; if (CAMPUS_INV_RE.test(s)) return { predicate: "project_investment_usd", scope: subjectHint ?? "facility", reason: "regex:project" }; return { predicate: "project_investment_usd", scope: sc.scope, reason: sc.reason }; } // ─── sanity engines ──────────────────────────────────────────────────────────────────────────────── export type FlagSeverity = "info" | "warn" | "critical"; export interface SanityFlag { code: string; severity: FlagSeverity; message: string; field?: string; value?: number | null; blocks?: boolean } export interface CapacitySanityInput { value: number; predicate: CapacityPredicate | null; scope: ClaimScope; /** the record the figure is being written to */ recordScope: "building" | "facility" | "campus" | "project"; previous?: number | null; context?: string | null; /** true when the facility / project name says campus / park / complex */ campusDesignation?: boolean; } const MONEY_NUM_RE = /(?:US\$|USD|\$|€|£|A\$|C\$|S\$)\s?(\d{1,3}(?:[.,]\d{3})*(?:\.\d+)?)\s*(?:million|billion|bn|m\b|b\b)?/gi; const MW_NUM_RE = /(\d{1,3}(?:[.,]\d{3})*(?:\.\d+)?)\s*(?:mw|gw|megawatts?|gigawatts?)\b/gi; /** "$300 million" and "300 MW" in the same sentence share a number: the unit may have been confused. */ export function moneyMwCollision(context: string | null | undefined): boolean { if (!context) return false; const money = new Set(); for (const m of context.matchAll(MONEY_NUM_RE)) money.add(m[1]!.replace(/,/g, "")); if (!money.size) return false; for (const m of context.matchAll(MW_NUM_RE)) if (money.has(m[1]!.replace(/,/g, ""))) return true; return false; } /** Deterministic capacity checks. `blocks: true` flags must keep the figure out of the record's columns. */ export function capacitySanity(i: CapacitySanityInput): SanityFlag[] { const out: SanityFlag[] = []; const v = i.value; if (!Number.isFinite(v) || v <= 0) return [{ code: "mw_invalid", severity: "critical", message: `invalid MW ${v}`, value: v, blocks: true }]; if (!isSiteScope(i.scope)) out.push({ code: `scope_${i.scope}`, severity: "critical", message: `figure describes a ${i.scope} (${i.scope === "unknown" ? "scope could not be determined" : "not this site"}) — kept as a claim, not assigned`, value: v, blocks: true }); if (i.scope === "campus" && i.recordScope === "building") out.push({ code: "scope_campus_on_building", severity: "critical", message: "campus-level figure on a building record", value: v, blocks: true }); if (v >= 20_000) out.push({ code: "mw_market_statistic", severity: "critical", message: `${v} MW is an industry / market statistic, not a site`, value: v, blocks: true }); if (v > 1_000 && i.recordScope !== "campus" && !i.campusDesignation && i.predicate !== "ultimate_campus_mw") out.push({ code: "mw_single_site_gt_1000", severity: "critical", message: `single facility above 1 000 MW (${v} MW) without a campus designation`, value: v }); if (v > 500 && i.recordScope === "building") out.push({ code: "mw_building_gt_500", severity: "warn", message: `one building above 500 MW (${v} MW)`, value: v }); if (i.previous != null && i.previous > 0 && (v / i.previous >= 5 || i.previous / v >= 5)) out.push({ code: "mw_change_5x", severity: "warn", message: `figure changed more than 5× (${i.previous} → ${v} MW)`, value: v }); if (i.context && moneyMwCollision(i.context)) out.push({ code: "mw_money_collision", severity: "critical", message: "a money figure with the same number sits in the same sentence — verify the unit", value: v }); if (i.predicate == null) out.push({ code: "mw_semantics_default", severity: "info", message: "sentence does not say what kind of MW this is; semantics defaulted from the record status", value: v }); if (i.predicate === "grid_connection_mw" || i.predicate === "utility_capacity_mw") out.push({ code: "mw_utility_not_it", severity: "info", message: "utility / grid figure — not IT load", value: v }); return out; } export interface InvestmentSanityInput { value: number; scope: ClaimScope; predicate: InvestmentPredicate; recordScope: "facility" | "campus" | "project"; previous?: number | null; context?: string | null } export function investmentSanity(i: InvestmentSanityInput): SanityFlag[] { const out: SanityFlag[] = []; const v = i.value; if (!Number.isFinite(v) || v <= 0) return [{ code: "inv_invalid", severity: "critical", message: `invalid investment ${v}`, value: v, blocks: true }]; if (!isSiteScope(i.scope)) out.push({ code: `inv_scope_${i.scope}`, severity: "critical", message: `investment describes a ${i.scope} — kept as a claim, not assigned`, value: v, blocks: true }); if (i.predicate === "deal_value_usd" || i.predicate === "multi_year_capex_usd" || i.predicate === "company_investment_usd" || i.predicate === "country_program_usd") out.push({ code: `inv_${i.predicate}`, severity: "critical", message: `${INVESTMENT_PREDICATE_LABEL[i.predicate]} is not a site investment`, value: v, blocks: true }); if (v > 500e9) out.push({ code: "inv_gt_500b", severity: "critical", message: "above $500B — an industry statistic", value: v, blocks: true }); if (v > 50e9) out.push({ code: "inv_single_site_gt_50b", severity: "critical", message: `single site investment above $50B (${Math.round(v / 1e9)}B) — requires verification`, value: v }); if (i.previous != null && i.previous > 0 && (v / i.previous >= 5 || i.previous / v >= 5)) out.push({ code: "inv_change_5x", severity: "warn", message: `investment changed more than 5× (${i.previous} → ${v})`, value: v }); if (i.context && moneyMwCollision(i.context)) out.push({ code: "inv_money_mw_collision", severity: "warn", message: "same number appears as MW in the sentence — verify", value: v }); return out; } // ─── evidence ────────────────────────────────────────────────────────────────────────────────────── export interface Evidence { text: string; start: number; end: number } /** Split into sentences with absolute offsets (abbreviation-tolerant enough for press prose). */ export function sentenceSpans(text: string): Array<{ text: string; start: number; end: number }> { const out: Array<{ text: string; start: number; end: number }> = []; const re = /[^.!?\n]+(?:[.!?]+(?=\s+[A-Z0-9"'“(]|\s*$)|\n|$)/g; for (const m of text.matchAll(re)) { const raw = m[0]; const lead = raw.length - raw.trimStart().length; const t = raw.trim(); if (!t) continue; out.push({ text: t, start: (m.index ?? 0) + lead, end: (m.index ?? 0) + lead + t.length }); } return out; } function numberVariants(value: number, unit: "mw" | "usd"): RegExp { if (unit === "mw") { const alts = new Set(); const push = (n: number, u: string) => { if (Number.isFinite(n)) alts.add(`${fmtNum(n)}\\+?\\s?(?:${u})`); }; push(value, "MW|megawatts?"); push(value / 1000, "GW|gigawatts?"); push(value * 1000, "kW|kilowatts?"); return new RegExp(`(?(); const push = (n: number, u: string) => { if (Number.isFinite(n) && n >= 1) alts.add(`(?:US\\$|USD ?|\\$|€|£|A\\$|C\\$|S\\$)\\s?${fmtNum(n)}\\s?(?:${u})`); }; push(value / 1e9, "billion|bn|b\\b"); push(value / 1e6, "million|mn|m\\b"); push(value / 1e12, "trillion|tn"); if (value < 1e6) push(value, ""); return new RegExp(`(?:${[...alts].join("|")})`, "i"); } function fmtNum(n: number): string { const r = Math.round(n * 1000) / 1000; const plain = String(r); const withCommas = r >= 1000 && Number.isInteger(r) ? r.toLocaleString("en-US") : null; const variants = [plain, withCommas].filter((x): x is string => !!x).map((x) => x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); // "1.5" also written "1,5"; integers may carry a trailing ".0" if (!Number.isInteger(r)) variants.push(plain.replace(".", ",").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); return `(?:${variants.join("|")})`; } /** The sentence that supports a figure (value in MW or USD). null when no sentence quotes it — then there is no claim. */ export function findEvidence(text: string | null | undefined, value: number, unit: "mw" | "usd"): Evidence | null { if (!text) return null; const re = numberVariants(value, unit); for (const s of sentenceSpans(text)) { if (re.test(s.text)) return { text: s.text.length > 600 ? `${s.text.slice(0, 597)}…` : s.text, start: s.start, end: s.end }; } return null; } // ─── project event classification ───────────────────────────────────────────────────────────────── export const PROJECT_CLASSES = ["NEW_BUILD", "EXPANSION", "CONSTRUCTION_START", "PERMIT", "LAND_ACQUISITION", "POWER_AGREEMENT", "GRID_CONNECTION", "FINANCING", "ACQUISITION", "PARTNERSHIP", "CUSTOMER_AGREEMENT", "EXECUTIVE_APPOINTMENT", "SUSTAINABILITY", "PRODUCT_NEWS", "GENERAL_COMPANY_NEWS", "UNKNOWN"] as const; export type ProjectClass = (typeof PROJECT_CLASSES)[number]; /** Classes that describe physical development and may create or materially modify a PROJECT. */ export const PHYSICAL_CLASSES: ReadonlySet = new Set(["NEW_BUILD", "EXPANSION", "CONSTRUCTION_START", "PERMIT", "LAND_ACQUISITION", "GRID_CONNECTION"]); /** Classes that may attach an EVENT to an identifiable project / company but never create a project. */ export const ASSOCIATED_CLASSES: ReadonlySet = new Set(["POWER_AGREEMENT", "FINANCING", "ACQUISITION", "PARTNERSHIP", "CUSTOMER_AGREEMENT"]); const CLASS_RULES: Array<[ProjectClass, RegExp]> = [ ["EXECUTIVE_APPOINTMENT", /\b(appoint(?:s|ed|ment)?|names? [A-Z][\w'-]+ [A-Z][\w'-]+ (?:as|to)|joins? (?:as|the board|its board)|joins? [A-Z][\w&.'-]+(?: [A-Z][\w&.'-]+){0,3} as (?:its )?(?:new )?(?:CEO|CFO|COO|CTO|CRO|CIO|CMO|chief|president|head|director|vice president|VP|SVP|EVP|managing director|general manager|chairman|chair|partner)|(?:new|hires?|promotes?|welcomes?|taps?) (?:[A-Z][\w'-]+ [A-Z][\w'-]+ as )?(?:CEO|CFO|COO|CTO|CRO|CIO|CMO|chief|president|head of|director|vice president|VP|managing director|general manager|board member|chairman|chair)|leadership (?:change|team|appointments?)|steps? down|retire(?:s|ment)|passing of|obituary|in memoriam|executive (?:team|hire|appointment))\b/i], ["GENERAL_COMPANY_NEWS", /\b(market (?:to|will|set to|expected to|projected to) (?:surpass|reach|hit|grow|exceed|top)|market size|cagr|forecast(?:s|ed)? (?:to|that)|report(?: finds| shows| reveals|:)|survey|study (?:finds|shows|reveals)|(?:index|trends?|predictions?|outlook|white ?paper|e-?book|webinar|podcast|interview|q&a|faq|explained|101\b|guide to|tips|best practices|case study|customer spotlight|award|shortlist|finalist|recogni[sz]ed|named (?:a |one of |to )?(?:top|best|leader))\b|quarterly results|half[- ]year results|annual results|earnings|revenue|ebitda|guidance|investor (?:day|presentation)|lawsuit|sues?\b|court|settlement|opinion|op-ed|why |how |what )/i], ["POWER_AGREEMENT", /\b(ppa\b|power purchase agreement|(?:virtual|corporate|long-term|\d+-year) (?:power|energy) (?:purchase )?agreement|(?:energy|power|electricity) (?:supply|offtake) (?:agreement|deal|contract)|offtake|(?:signs?|signed|inks?|inked|secures?|secured) (?:a )?(?:\d+[ -]?(?:mw|gw) )?(?:of )?(?:solar|wind|nuclear|geothermal|hydro|gas|renewable|clean|carbon-free) (?:power|energy|capacity|supply)|(?:solar|wind|nuclear|geothermal|hydro|gas|battery|fuel cell|smr|reactor)s? (?:to power|will power|powering|deal|plant to supply|farm to supply)|behind-the-meter|on-site (?:generation|power plant|gas plant|solar)|microgrid|small modular reactor)\b/i], ["SUSTAINABILITY", /\b(sustainability (?:report|goals?|targets?|commitment|strategy)|net[- ]zero|carbon[- ]neutral|carbon[- ]free|esg\b|renewable (?:energy )?(?:certificates?|credits?|target|goal|commitment)|100% renewable|water (?:positive|stewardship|usage|conservation)|emissions? (?:reduction|target)|leed (?:gold|platinum|certif)|green (?:building|certification)|energy efficiency (?:program|initiative)|climate (?:pledge|commitment)|biodiversity|tree planting|heat (?:reuse|recovery) (?:program|scheme|initiative))\b/i], ["GRID_CONNECTION", /\b(grid connection|grid-?connected|interconnection (?:agreement|request|capacity|queue|study)|connection agreement|(?:new |expanded )?substation|transmission (?:line|upgrade|project|capacity)|energi[sz]ation|energi[sz]ed|large[- ]load (?:tariff|request|agreement|customer)|load (?:request|study|letter)|(?:secured|reserved|allocated|approved) (?:\d+[ -]?(?:mw|gw) of )?(?:grid |utility )?(?:power|capacity) (?:from|with) (?:the utility|[A-Z][\w&]+ (?:energy|power|electric|utility)))\b/i], ["FINANCING", /\b(financ(?:es|ing|ed)|refinanc\w+|backs? [A-Z][\w'’.-]+|guarantees?|(?:debt|notes?|bond|equity|securit\w+) (?:issuance|offering|placement|raise)|raises? (?:\$|€|£|us\$)|raised (?:\$|€|£|us\$)|series [a-e]\b|funding (?:round|arranged|secured)|loan|credit facility|green bond|bonds?\b|notes? offering|debt (?:facility|package|raise)|equity (?:raise|investment|stake)|ipo\b|capital raise|construction (?:loan|financing)|securitization|abs\b|term loan|(?:secures?|secured|closes?|closed) (?:\$|€|£|us\$)[\d.,]+ ?(?:m|bn|b|million|billion)? (?:in )?(?:financing|loan|facility|debt|funding))\b/i], ["ACQUISITION", /\b(acqui(?:res?|red|sition) (?:of )?(?:[A-Z][\w&'.-]+ )+(?:group|holdings|inc|ltd|llc|limited|corp|sa|ag|plc|pte|data ?cent(?:er|re)s?|portfolio|platform|company|business)|(?:acquires?|acquired|acquiring|buys?|bought|buying|purchases?|purchased|purchasing|takes? over|took over) (?:a |an |the |its |two |three |four |five |six |\d+ )?(?:\d+[ -]?(?:mw|gw) )?(?:(?:hyperscale|colocation|carrier-neutral|edge|operational|existing) )?(?:data ?cent(?:er|re)s?|facility|facilities|portfolio|platform|operator|business|company|stake|campus from|site from)|acquisition of|takeover|buyout|merger|merges? with|to be acquired|stake in|sells?|sold|divest\w+|sale of)\b/i], ["CUSTOMER_AGREEMENT", /\b([$€£][\d.,]+ ?(?:bn|b|m|million|billion)? (?:\w+ )?leases?|\bleases?\b(?! (?:land|a site|the site))|(?:signs?|signed|secures?|secured|inks?|inked|lands?|landed|wins?|won|announces?) (?:a |an |its |the )?(?:multi-year |long-term |major |anchor |hyperscale |\d+[ -]?(?:mw|gw) )?(?:colocation |hosting |capacity |wholesale |pre-?)?(?:lease|leases|tenant|customer|contract|agreement|deal) (?:with|for|from)|(?:lease|leases|leased) (?:\d+[ -]?(?:mw|gw)|[\d,]+ (?:sq|square))|(?:selects?|selected|chooses?|chose|picks?|picked|taps?|tapped) [A-Z][\w&'.-]+ (?:for|as|to)|(?:hosting|colocation|lease|leasing|services?|master|supply|framework|capacity) agreements?|pre-?leas\w+|fully leased|anchor tenant|moves? into|deploys? (?:in|at|with)|expands? (?:its )?(?:footprint|presence) (?:in|at|with) [A-Z][\w&'.-]+(?:'s)? (?:data ?cent(?:er|re)|facility|campus))\b/i], ["PARTNERSHIP", /\b(partnership|partners? with|joint venture|\bjv\b|join(?:s|ed)? forces|team(?:s|ed)? up|collaborat(?:es?|ion|ing)|alliance|memorandum of understanding|\bmou\b|strategic (?:agreement|relationship|cooperation)|framework agreement)\b/i], ["PRODUCT_NEWS", /\b(launches? (?:a )?(?:new )?(?:service|platform|product|portal|program|programme|offering|solution|marketplace|tool|api|feature)|introduces? (?:a )?(?:new )?(?:service|platform|product|offering|solution)|now available|available (?:now|in|from)|unveils? (?:a )?(?:new )?(?:service|platform|product|offering|solution|brand|logo|website)|rebrand|new (?:brand|logo|website|identity)|integrat(?:es|ion) with|certified by|(?:earns?|achieves?|receives?) (?:[A-Z]\w+ )?(?:certification|rating|accreditation|iso \d))\b/i], ["LAND_ACQUISITION", /\b((?:acquires?|acquired|buys?|bought|purchases?|purchased|secures?|secured|closes? on|closed on|options?) (?:a |an |the |its |additional |another )?(?:\d[\d,.]*[ -]?(?:acre|hectare|ha)s?|land|site|parcel|plot|property|farmland|lot)|land (?:purchase|acquisition|deal|bank)|(?:acre|hectare) (?:site|parcel|plot|property) (?:acquired|purchased|bought)|site (?:acquisition|purchase)|zoned land|land for (?:a |its |the )?(?:new )?(?:data ?cent(?:er|re)|campus))\b/i], ["PERMIT", /\b(planning (?:application|permission|approval|consent|committee|commission|board|inquiry)|permit(?:s|ted|ting)?\b|rezon\w+|zoning (?:approval|change|request|case|board|commission)|(?:files?|filed|submits?|submitted|lodges?|lodged) (?:plans?|an application|a planning|planning|for approval|proposal)|(?:approv(?:es|ed|al)|green-?light(?:s|ed)?|consent(?:s|ed)?|clears?|cleared|greenlit|(?:votes?|voted) to approve|unanimously approved|signs? off|sign-off) (?:for |of |on )?(?:a |the |its |\d+[ -]?(?:mw|gw) )?(?:data ?cent(?:er|re)|campus|project|plans?|development|proposal|application|rezoning|site plan|special use|conditional use)|environmental (?:impact|assessment|review|permit)|eia\b|site plan (?:approval|review)|special (?:use|exception) permit|conditional use permit|comprehensive plan amendment|public hearing|(?:county|city|town|council|board|commission|supervisors) (?:approves?|approved|rejects?|rejected|denies|denied|defers?|deferred|delays?|tables?|tabled))\b/i], ["CONSTRUCTION_START", /\b(breaks? ground|broke ground|groundbreaking|ground-?breaking|construction (?:has |have )?(?:started|begins?|began|begun|commenced|commences|kicks? off|is under ?way|under ?way|gets under ?way)|(?:begins?|began|starts?|started|commences?|commenced|kicks? off) (?:construction|building|work|site work|vertical construction)|under construction|topped out|topping out|tops out|steel (?:erection|going up)|first concrete|shovels in the ground|site work (?:begins|has begun|under ?way)|construction (?:milestone|update|progress))\b/i], ["EXPANSION", /\b(expan(?:sion|ds?|ded|ding)|adds? (?:\d+[ -]?(?:mw|gw)|capacity|a (?:second|third|fourth|new) (?:building|data hall|phase))|additional (?:\d+[ -]?(?:mw|gw)|capacity|building|data hall|phase)|(?:second|third|fourth|fifth|next|new) (?:phase|building|data hall|hall|facility (?:at|on|in) (?:its|the) (?:existing )?campus)|phase (?:2|3|4|ii|iii|iv|two|three|four)\b|(?:grows?|growing|scales?|scaling|extends?|extending|enlarges?|doubles?|doubling|triples?|tripling) (?:its |the )?(?:capacity|campus|footprint at|data cent(?:er|re)|site|facility)|(?:grows?|expands?|extends?) [A-Z][\w .-]{0,40}?(?:campus|data cent(?:er|re)|facility|site)\b|more capacity (?:at|in|to)|further (?:capacity|building|facility|phase)|extension (?:of|to) (?:its|the) (?:existing )?(?:data cent(?:er|re)|campus|facility|site))\b/i], ["NEW_BUILD", /\b((?:to|will|would|could|may|might|plans? to|set to|aims? to|intends? to|proposes? to|wants? to|is|are|is expected to|expected to) (?:build|construct|develop|create|deliver|establish|open|erect|bring)|(?:plans?|planned|planning|proposes?|proposed|proposal|announces?|announced|unveils?|unveiled|reveals?|revealed|confirms?|confirmed|launches?|launched|commits? to|committed to|pledges?|earmarks?|eyes|eyeing|mulls?|weighs?|considering|exploring|in talks) (?:for |to build |to develop |to construct |to invest in |to open |a |an |its |the |new |first |second |two |three |\d+[ -]?(?:mw|gw) |\$[\d.,]+ ?(?:m|bn|b|million|billion)? |€[\d.,]+ ?(?:m|bn|b|million|billion)? |£[\d.,]+ ?(?:m|bn|b|million|billion)? |at least [€$£][\d.,]+ ?(?:m|bn|b|million|billion)? |hyperscale |ai |massive |major |huge |giant |sprawling |\w+-acre |[“"']?[\w.-]{2,14}[”"']?,? ){0,4}(?:data ?cent(?:er|re)s?|campus|campuses|mega-?campus|facility|facilities|ai factory|ai factories|(?:ai |digital |cloud |compute |sovereign )infrastructure|hub|site|development|project|complex|cluster|supercomputer|data hall)|new (?:\d+[ -]?(?:mw|gw) |hyperscale |ai |\$[\d.,]+ ?(?:m|bn|b|million|billion) )?(?:data ?cent(?:er|re)|campus|facility|ai factory|hub) (?:in|near|at|for|to|planned|proposed|coming|slated|set)|(?:\d+[ -]?(?:mw|gw)) (?:ai )?(?:data ?cent(?:er|re)|campus|facility|site|project|development|hub) (?:in|near|at|for|planned|proposed)|(?:build|building|develop|developing|construct|constructing|open|opening) (?:a |an |its |the |new |first |\d+[ -]?(?:mw|gw) )*(?:data ?cent(?:er|re)|campus|facility|ai factory) (?:in|near|at|on|for)|first (?:data ?cent(?:er|re)|campus|facility) in|(?:enters?|entering|entry into) (?:the )?[A-Z][\w.-]+ (?:market|with)|coming to|slated for|(?:to|will|would|could) (?:house|host|feature|include) (?:a |an |up to )?(?:\d+[ -]?(?:mw|gw)|data ?cent(?:er|re)|campus))\b/i], ]; /** Verbs / nouns that describe physical development (build, construct, break ground, expand, permit, approve, file, acquire land, energize…). */ export const DEVELOPMENT_VERB_RE = /\b(build|builds|building|built|build-?out|construct\w*|develop\w*|break\w* ground|broke ground|groundbreaking|expan\w+|open|opens|opening|opened|permit\w*|approv\w+|file[sd]?|filing|rezon\w+|acquire[sd]? (?:\d[\d,.]*[ -]?(?:acre|hectare)s?|land|a site|the site|a parcel)|land (?:purchase|acquisition)|energi[sz]\w+|deliver\w*|commission\w*|top\w* out|takes? shape|rising|going up|nears? completion|advances?|scales? up|commits? (?:at least |up to |over |more than )?[€$£]\d|invest\w* (?:at least |up to |over |more than )?[€$£]\d|(?:triples?|doubles?|increases?) (?:its |their )?investment|investment in|unveil\w* (?:plans?|a |an |its |new |\d|[€$£])|announc\w* (?:plans? (?:for|to)|a new|new|its (?:first|second|next)|\d+[ -]?(?:mw|gw))|announcement of|plans?\b|planned|propos\w* (?:a |an |new |\d)|proposed|(?:a |two |three |four |second |third |its first |first |new |newest |another )(?:new )?(?:hyperscale |ai |ai-ready |\d+[ -]?(?:mw|gw) )?(?:campus(?:es)?|data ?cent(?:er|re)s?|facilit(?:y|ies)|data halls?)|site plan|planning application|zoning|\d[\d.,]*\s?(?:mw|gw)\s+(?:[\w€$£,]+\s){0,3}(?:campus|project|data ?cent(?:er|re)|facility|site|hub|megacampus))(?![A-Za-z])/i; /** Site nouns — a headline must name a site AND carry a development verb before a status word can make it a project. */ export const SITE_NOUN_RE = /\b(data ?cent(?:er|re)s?|campus(?:es)?|mega-?campus(?:es)?|facility|facilities|ai factor(?:y|ies)|hub|site|data hall|(?:ai |digital |cloud |compute |sovereign )infrastructure)\b/i; export interface ProjectClassInput { title: string | null | undefined; /** the lead of the article (first ~600 characters) */ lead?: string | null; /** an explicit project / facility name was found */ hasExplicitName?: boolean; /** a recognised operator is the headline subject or the primary operator */ hasOperator?: boolean; /** a city / region / country was found in the title or lead */ hasLocation?: boolean; /** the title-derived status (pipeline / operational / null) */ status?: string | null; /** the source is the operator / a government / a utility itself: the lead may supply the class and the verb ("Hello, Tulsa!") */ trustLead?: boolean; } export interface ProjectClassification { class: ProjectClass; /** true when the class may create or materially modify a project */ physical: boolean; /** true when the class may attach an event to an identifiable project / company */ associated: boolean; /** evidence threshold for creating a project: (explicit name or operator) + location + development verb */ evidence: { name: boolean; operator: boolean; location: boolean; verb: boolean; strength: "strong" | "weak" | "none" }; /** true only for physical classes with strong evidence */ mayCreateProject: boolean; reasons: string[]; } /** * Classify what an announcement is about BEFORE anything becomes a project. The title decides; the lead breaks ties when * the title is generic ("Company announces …"). Order of the rules = precedence: people, market research and * sustainability news are vetoed first, then money / deals / customers, then physical development classes. */ export function classifyProjectEvent(i: ProjectClassInput): ProjectClassification { const title = (i.title ?? "").replace(/\s+/g, " ").trim(); const lead = (i.lead ?? "").replace(/\s+/g, " ").slice(0, 600); const reasons: string[] = []; let cls: ProjectClass = "UNKNOWN"; const physicalTitle = ["CONSTRUCTION_START", "EXPANSION", "NEW_BUILD", "PERMIT", "LAND_ACQUISITION", "GRID_CONNECTION"].some((c) => CLASS_RULES.find(([k]) => k === c)![1].test(title)); for (const [k, re] of CLASS_RULES) { if (re.test(title)) { // a money / deal / partnership headline that also says "to build 300MW campus" is a build headline if (["FINANCING", "ACQUISITION", "CUSTOMER_AGREEMENT", "PARTNERSHIP", "POWER_AGREEMENT", "PRODUCT_NEWS", "SUSTAINABILITY"].includes(k) && physicalTitle && /\b(to build|will build|to develop|to construct|breaks? ground|broke ground|groundbreaking|new (?:\d+[ -]?(?:mw|gw) )?(?:data ?cent(?:er|re)|campus)|\d+[ -]?(?:mw|gw) (?:data ?cent(?:er|re)|campus)|expansion|expands? (?:its|the) (?:campus|data ?cent(?:er|re)))\b/i.test(title)) { reasons.push(`title:${k}+build→physical`); continue; } cls = k; reasons.push(`title:${k}`); break; } } if (cls === "UNKNOWN" && lead) { for (const [k, re] of CLASS_RULES) { // the lead may only add physical / associated classes — a people or market veto must be visible in the headline if (["EXECUTIVE_APPOINTMENT", "GENERAL_COMPANY_NEWS", "SUSTAINABILITY", "PRODUCT_NEWS"].includes(k)) continue; if (re.test(lead)) { cls = k; reasons.push(`lead:${k}`); break; } } } // status-derived fallbacks need the headline itself to talk about building / a site ("Southern's 17 GW pipeline puts AI power // demand into utility math" has an "announced" status word somewhere in the body but is an analysis piece) const titleVerb = DEVELOPMENT_VERB_RE.test(title); const titleSite = SITE_NOUN_RE.test(title); if (cls === "UNKNOWN" && i.status && /^(announced|proposed|rumored)$/.test(i.status) && titleVerb && titleSite) { cls = "NEW_BUILD"; reasons.push("status:announced→NEW_BUILD"); } if (cls === "UNKNOWN" && i.status === "under_construction" && titleSite) { cls = "CONSTRUCTION_START"; reasons.push("status:construction"); } if (cls === "UNKNOWN" && (i.status === "permitting" || i.status === "approved") && titleSite) { cls = "PERMIT"; reasons.push(`status:${i.status}`); } if (cls === "UNKNOWN" && i.status === "expansion" && titleSite) { cls = "EXPANSION"; reasons.push("status:expansion"); } const classFromTitle = reasons.some((r) => r.startsWith("title:")); const verb = titleVerb || ((classFromTitle || !!i.trustLead) && !!lead && DEVELOPMENT_VERB_RE.test(lead)); const name = !!i.hasExplicitName; const operator = !!i.hasOperator; const location = !!i.hasLocation; const strength: ProjectClassification["evidence"]["strength"] = (name || operator) && location && verb ? "strong" : (name || operator || location) && verb ? "weak" : "none"; const physical = PHYSICAL_CLASSES.has(cls); const associated = ASSOCIATED_CLASSES.has(cls); return { class: cls, physical, associated, evidence: { name, operator, location, verb, strength }, mayCreateProject: physical && strength === "strong", reasons }; } // ─── project lifecycle state machine ─────────────────────────────────────────────────────────────── export const PROJECT_STAGES = ["rumored", "proposed", "announced", "permitting", "approved", "under_construction", "partially_operational", "operational", "delayed", "cancelled"] as const; export type ProjectStage = (typeof PROJECT_STAGES)[number]; const STAGE_ORDER: Record = { rumored: 0, proposed: 1, announced: 2, permitting: 3, approved: 4, under_construction: 5, partially_operational: 6, operational: 7, expansion: 7 }; export type TransitionVerdict = "forward" | "side" | "resume" | "same" | "backward" | "invalid"; /** * Allowed lifecycle transitions. Forward moves along the pipeline are always fine; `delayed` / `cancelled` are side * branches reachable from any pre-operational stage; leaving `delayed` resumes the pipeline; a backward move * (operational → announced) is rejected unless the incoming source outranks the stored one (caller decides). */ export function projectTransition(from: string | null | undefined, to: string | null | undefined): TransitionVerdict { if (!to) return "invalid"; if (!from || from === "unknown") return "forward"; if (from === to) return "same"; if (to === "delayed" || to === "cancelled") return from === "operational" ? "invalid" : "side"; if (from === "cancelled") return to === "announced" || to === "proposed" || to === "rumored" ? "resume" : "invalid"; if (from === "delayed") return "resume"; const a = STAGE_ORDER[from], b = STAGE_ORDER[to]; if (a == null || b == null) return "invalid"; return b > a ? "forward" : "backward"; } // ─── AI evidence levels ──────────────────────────────────────────────────────────────────────────── export const AI_EVIDENCE_LEVELS = ["confirmed", "likely", "associated", "unknown"] as const; export type AiEvidence = (typeof AI_EVIDENCE_LEVELS)[number]; const AI_CONFIRMED_RE = /\b(ai (?:data ?cent(?:er|re)|campus|factory|factories|infrastructure|cluster|supercomputer|compute (?:campus|facility|site)|training (?:facility|site|campus))|(?:purpose-?built|dedicated|designed) (?:for|to) (?:ai|artificial intelligence|accelerated computing|gpu|hpc|high-performance computing|machine learning|large language models?|llms?|training)|hpc (?:data ?cent(?:er|re)|campus|facility|cluster)|supercomput(?:er|ing) (?:cent(?:er|re)|facility|campus)|gpu (?:cluster|campus|farm|cloud|data ?cent(?:er|re)|hosting)|accelerated computing (?:facility|campus|data ?cent(?:er|re)|infrastructure)|exascale|(?:nvidia|blackwell|hopper|gb\d{3}|h\d{3}|b\d{3}) (?:gpus?|systems?|superchips?|clusters?))\b/i; const AI_LIKELY_RE = /\b(ai-?ready|ai-?optimi[sz]ed|high[- ]density|liquid[- ]cool\w*|direct-to-chip|immersion cool\w*|rear-door heat exchangers?|(?:\d{2,3}|1\d{2}) ?kw (?:per |\/ ?)rack|gpu|accelerated computing|hpc|machine learning|inference|training clusters?|sovereign ai|ai workloads?)\b/i; const AI_ASSOCIATED_RE = /\b(openai|anthropic|xai|coreweave|lambda labs|crusoe|nebius|nscale|fluidstack|voltage park|stargate|ai (?:tenant|customer|lease|demand|boom|workloads?)|for ai|ai and cloud|cloud and ai)\b/i; /** Evidence level that a facility / project is AI/HPC infrastructure. One "AI" keyword never confirms it. */ export function classifyAiEvidence(text: string | null | undefined): { level: AiEvidence; evidence: string | null } { if (!text) return { level: "unknown", evidence: null }; const s = text.replace(/\s+/g, " "); const c = AI_CONFIRMED_RE.exec(s); if (c) return { level: "confirmed", evidence: c[0] }; const l = AI_LIKELY_RE.exec(s); if (l) return { level: "likely", evidence: l[0] }; const a = AI_ASSOCIATED_RE.exec(s); if (a) return { level: "associated", evidence: a[0] }; return { level: "unknown", evidence: null }; } // ─── event significance ──────────────────────────────────────────────────────────────────────────── export type Significance = "major" | "medium" | "minor"; export function significanceBand(score: number): Significance { return score >= 75 ? "major" : score >= 45 ? "medium" : "minor"; }