diff --git a/apps/longterm-next/src/app/internal/facts/page.tsx b/apps/longterm-next/src/app/internal/facts/page.tsx index a1fe7c16..868e9c0e 100644 --- a/apps/longterm-next/src/app/internal/facts/page.tsx +++ b/apps/longterm-next/src/app/internal/facts/page.tsx @@ -25,7 +25,8 @@ export default function FactsPage() {

Canonical Facts Dashboard

All canonical facts from the YAML fact store, used by the <F> component. - Facts are defined in apps/longterm/src/data/facts/*.yaml. + Facts are defined in src/data/facts/*.yaml and processed at build time + with support for numeric parsing and computed expressions.

diff --git a/apps/longterm-next/src/components/mdx-components.tsx b/apps/longterm-next/src/components/mdx-components.tsx index eeb64ce5..f22b4e5e 100644 --- a/apps/longterm-next/src/components/mdx-components.tsx +++ b/apps/longterm-next/src/components/mdx-components.tsx @@ -17,6 +17,12 @@ import { TransitionModelContent } from "@/components/wiki/TransitionModelContent import TransitionModelTable from "@/components/wiki/TransitionModelTable"; import { TransitionModelInteractive } from "@/components/wiki/TransitionModelTable"; import { FactorSubItemsList, AllFactorsSubItems } from "@/components/wiki/FactorSubItemsList"; +import { FactorKeyDebates } from "@/components/wiki/FactorKeyDebates"; +import { FactorRatings } from "@/components/wiki/FactorRatings"; +import { FactorScope } from "@/components/wiki/FactorScope"; +import { FactorRelatedContent } from "@/components/wiki/FactorRelatedContent"; +import { FactorRelationshipDiagram, FullModelDiagram } from "@/components/wiki/FactorRelationshipDiagram"; +import { FactorStatusBadge } from "@/components/wiki/FactorStatusCard"; import CauseEffectGraph from "@/components/wiki/CauseEffectGraph"; import { PageCauseEffectGraph } from "@/components/wiki/PageCauseEffectGraph"; @@ -40,8 +46,8 @@ const stubNames = [ "Badge", "ConceptsDirectory", "Crux", "CruxList", "DataCrux", "DataEstimateBox", "DisagreementMap", "DualOutcomeChart", "EntityGraph", "EstimateBox", - "FactorAttributionMatrix", "FactorGauges", "FactorRelationshipDiagram", - "FullModelDiagram", "FullWidthLayout", "ImpactGrid", + "FactorAttributionMatrix", "FactorGauges", + "FullWidthLayout", "ImpactGrid", "ImpactList", "InsightGridExperiments", "InsightScoreMatrix", "InsightsTable", "KeyPeople", "KeyQuestions", "KnowledgeTreemap", "ModelsList", "OutcomesTable", "PageIndex", "PixelDensityMap", @@ -95,6 +101,13 @@ export const mdxComponents: Record> = { TransitionModelInteractive, FactorSubItemsList, AllFactorsSubItems, + FactorKeyDebates, + FactorRatings, + FactorScope, + FactorRelatedContent, + FactorRelationshipDiagram, + FullModelDiagram, + FactorStatusBadge, // Cause-Effect Graph components CauseEffectGraph, diff --git a/apps/longterm-next/src/components/wiki/FactorKeyDebates.tsx b/apps/longterm-next/src/components/wiki/FactorKeyDebates.tsx new file mode 100644 index 00000000..b7571e3b --- /dev/null +++ b/apps/longterm-next/src/components/wiki/FactorKeyDebates.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { + getSubItemDebates, + type KeyDebate, +} from "@/data/parameter-graph-data"; + +interface FactorKeyDebatesProps { + nodeId?: string; + subItemLabel?: string; + debates?: KeyDebate[]; + title?: string; +} + +export function FactorKeyDebates({ + nodeId, + subItemLabel, + debates: directDebates, + title = "Key Debates", +}: FactorKeyDebatesProps) { + const debates = + directDebates || + (nodeId && subItemLabel ? getSubItemDebates(nodeId, subItemLabel) : []); + + if (debates.length === 0) { + return null; + } + + return ( +
+
+ {title} + + from YAML + +
+
+ + + + + + + + + {debates.map((debate, i) => ( + + + + + ))} + +
DebateCore Question
{debate.topic}{debate.description}
+
+
+ ); +} + +export default FactorKeyDebates; diff --git a/apps/longterm-next/src/components/wiki/FactorRatings.tsx b/apps/longterm-next/src/components/wiki/FactorRatings.tsx new file mode 100644 index 00000000..fb41eae3 --- /dev/null +++ b/apps/longterm-next/src/components/wiki/FactorRatings.tsx @@ -0,0 +1,110 @@ +import React from "react"; +import { + getSubItemRatings, + type SubItemRatings, +} from "@/data/parameter-graph-data"; + +function getInterpretation( + value: number, + metric: keyof SubItemRatings +): string { + const interpretations: Record< + keyof SubItemRatings, + Record + > = { + changeability: { + low: "Very difficult to change", + medium: "Moderately changeable", + high: "Relatively easy to influence", + }, + xriskImpact: { + low: "Low direct existential impact", + medium: "Moderate existential impact", + high: "High direct existential impact", + }, + trajectoryImpact: { + low: "Low long-term effects", + medium: "Moderate long-term effects", + high: "High long-term effects", + }, + uncertainty: { + low: "Lower uncertainty", + medium: "Moderate uncertainty", + high: "High uncertainty", + }, + }; + + const level = value <= 33 ? "low" : value <= 66 ? "medium" : "high"; + return interpretations[metric][level]; +} + +interface FactorRatingsProps { + nodeId?: string; + subItemLabel?: string; + ratings?: SubItemRatings; + title?: string; +} + +export function FactorRatings({ + nodeId, + subItemLabel, + ratings: directRatings, + title = "Ratings", +}: FactorRatingsProps) { + const ratings = + directRatings || + (nodeId && subItemLabel + ? getSubItemRatings(nodeId, subItemLabel) + : undefined); + + if (!ratings) { + return null; + } + + const metrics: Array<{ key: keyof SubItemRatings; label: string }> = [ + { key: "changeability", label: "Changeability" }, + { key: "xriskImpact", label: "X-risk Impact" }, + { key: "trajectoryImpact", label: "Trajectory Impact" }, + { key: "uncertainty", label: "Uncertainty" }, + ]; + + return ( +
+
+ {title} + + from YAML + +
+
+ + + + + + + + + + {metrics.map(({ key, label }) => { + const value = ratings[key]; + if (value === undefined) return null; + return ( + + + + + + ); + })} + +
MetricScoreInterpretation
{label}{value}/100{getInterpretation(value, key)}
+
+
+ ); +} + +export default FactorRatings; diff --git a/apps/longterm-next/src/components/wiki/FactorRelatedContent.tsx b/apps/longterm-next/src/components/wiki/FactorRelatedContent.tsx new file mode 100644 index 00000000..a6272d86 --- /dev/null +++ b/apps/longterm-next/src/components/wiki/FactorRelatedContent.tsx @@ -0,0 +1,93 @@ +import React from "react"; +import { + getSubItemRelatedContent, + type RelatedContent, + type RelatedContentLink, +} from "@/data/parameter-graph-data"; + +interface FactorRelatedContentProps { + nodeId?: string; + subItemLabel?: string; + relatedContent?: RelatedContent; + title?: string; +} + +function LinkList({ + links, + category, +}: { + links: RelatedContentLink[]; + category: string; +}) { + if (links.length === 0) return null; + + return ( +
+

Related {category}

+ +
+ ); +} + +export function FactorRelatedContent({ + nodeId, + subItemLabel, + relatedContent: directContent, + title = "Related Content", +}: FactorRelatedContentProps) { + const content = + directContent || + (nodeId && subItemLabel + ? getSubItemRelatedContent(nodeId, subItemLabel) + : undefined); + + if (!content) { + return null; + } + + const hasContent = + (content.risks?.length || 0) > 0 || + (content.responses?.length || 0) > 0 || + (content.models?.length || 0) > 0 || + (content.cruxes?.length || 0) > 0; + + if (!hasContent) { + return null; + } + + return ( +
+
+ {title} + + from YAML + +
+
+ {content.risks && } + {content.responses && ( + + )} + {content.models && ( + + )} + {content.cruxes && ( + + )} +
+
+ ); +} + +export default FactorRelatedContent; diff --git a/apps/longterm-next/src/components/wiki/FactorRelationshipDiagram.tsx b/apps/longterm-next/src/components/wiki/FactorRelationshipDiagram.tsx new file mode 100644 index 00000000..be3082dc --- /dev/null +++ b/apps/longterm-next/src/components/wiki/FactorRelationshipDiagram.tsx @@ -0,0 +1,259 @@ +import * as React from "react"; +import { MermaidDiagram } from "@/components/wiki/MermaidDiagram"; +import { + getRootFactors, + getScenarios, + getOutcomes, + getEdgesFrom, + getEdgesTo, + type RootFactor, +} from "@/data/parameter-graph-data"; + +interface FactorRelationshipDiagramProps { + /** The factor/scenario/outcome ID to center the diagram on */ + nodeId: string; + /** Direction: 'outgoing' shows what this node affects, 'incoming' shows what affects it, 'both' shows all */ + direction?: "outgoing" | "incoming" | "both"; + /** Include the node's sub-items in the diagram */ + showSubItems?: boolean; + /** Diagram orientation */ + orientation?: "TD" | "LR"; +} + +// Get node by ID +function getNodeById(nodeId: string): RootFactor | undefined { + const allNodes = [...getRootFactors(), ...getScenarios(), ...getOutcomes()]; + return allNodes.find((n) => n.id === nodeId); +} + +// Get node type for styling +function getNodeType( + nodeId: string +): "factor" | "scenario" | "outcome" | "unknown" { + const factors = getRootFactors(); + const scenarios = getScenarios(); + const outcomes = getOutcomes(); + + if (factors.find((n) => n.id === nodeId)) return "factor"; + if (scenarios.find((n) => n.id === nodeId)) return "scenario"; + if (outcomes.find((n) => n.id === nodeId)) return "outcome"; + return "unknown"; +} + +// Generate node ID safe for Mermaid +function mermaidId(id: string): string { + return id.replace(/-/g, "_"); +} + +// Generate Mermaid style based on node type +function getNodeStyle( + nodeType: "factor" | "scenario" | "outcome" | "unknown" +): string { + switch (nodeType) { + case "factor": + return "fill:#e3f2fd"; + case "scenario": + return "fill:#fff3e0"; + case "outcome": + return "fill:#ffebee"; + default: + return "fill:#f5f5f5"; + } +} + +export function FactorRelationshipDiagram({ + nodeId, + direction = "both", + showSubItems = false, + orientation = "TD", +}: FactorRelationshipDiagramProps) { + const node = getNodeById(nodeId); + + if (!node) { + return ( + Node not found: {nodeId} + ); + } + + const allNodes = [...getRootFactors(), ...getScenarios(), ...getOutcomes()]; + const nodeMap = new Map(allNodes.map((n) => [n.id, n])); + + // Get edges + const outgoingEdges = direction !== "incoming" ? getEdgesFrom(nodeId) : []; + const incomingEdges = direction !== "outgoing" ? getEdgesTo(nodeId) : []; + + // Collect all node IDs we need to show + const nodeIds = new Set([nodeId]); + outgoingEdges.forEach((e) => nodeIds.add(e.target)); + incomingEdges.forEach((e) => nodeIds.add(e.source)); + + // Build Mermaid syntax + const lines: string[] = [`flowchart ${orientation}`]; + + // Define nodes + nodeIds.forEach((id) => { + const n = nodeMap.get(id); + if (n) { + const mid = mermaidId(id); + lines.push(` ${mid}["${n.label}"]`); + } + }); + + // Add sub-items if requested + if (showSubItems && node.subItems && node.subItems.length > 0) { + const mid = mermaidId(nodeId); + lines.push(` subgraph ${mid}_sub["${node.label} Components"]`); + node.subItems.forEach((item, i) => { + const subId = `${mid}_s${i}`; + lines.push(` ${subId}["${item.label}"]`); + }); + lines.push(` end`); + // Connect sub-items to main node + node.subItems.forEach((_, i) => { + const subId = `${mid}_s${i}`; + lines.push(` ${subId} --> ${mid}`); + }); + } + + // Add edges + outgoingEdges.forEach((edge) => { + const sourceId = mermaidId(edge.source); + const targetId = mermaidId(edge.target); + const label = + edge.effect === "increases" + ? "increases" + : edge.effect === "decreases" + ? "decreases" + : ""; + if (label) { + lines.push(` ${sourceId} -->|${label}| ${targetId}`); + } else { + lines.push(` ${sourceId} --> ${targetId}`); + } + }); + + incomingEdges.forEach((edge) => { + const sourceId = mermaidId(edge.source); + const targetId = mermaidId(edge.target); + const label = + edge.effect === "increases" + ? "increases" + : edge.effect === "decreases" + ? "decreases" + : ""; + // Only add if not already added by outgoing + if (direction === "incoming" || edge.source !== nodeId) { + if (label) { + lines.push(` ${sourceId} -->|${label}| ${targetId}`); + } else { + lines.push(` ${sourceId} --> ${targetId}`); + } + } + }); + + // Add styles + nodeIds.forEach((id) => { + const nodeType = getNodeType(id); + const mid = mermaidId(id); + const style = getNodeStyle(nodeType); + lines.push(` style ${mid} ${style}`); + }); + + const chart = lines.join("\n"); + + return ; +} + +export function FullModelDiagram({ + orientation = "TD", + simplified = false, +}: { + orientation?: "TD" | "LR"; + simplified?: boolean; +}) { + const factors = getRootFactors(); + const scenarios = getScenarios(); + const outcomes = getOutcomes(); + + const lines: string[] = [`flowchart ${orientation}`]; + + // Subgraph for factors + lines.push(` subgraph Factors["Root Factors"]`); + factors.forEach((f) => { + const mid = mermaidId(f.id); + lines.push(` ${mid}["${f.label}"]`); + }); + lines.push(` end`); + + // Subgraph for scenarios + lines.push(` subgraph Scenarios["Ultimate Scenarios"]`); + scenarios.forEach((s) => { + const mid = mermaidId(s.id); + lines.push(` ${mid}["${s.label}"]`); + }); + lines.push(` end`); + + // Subgraph for outcomes + lines.push(` subgraph Outcomes["Ultimate Outcomes"]`); + outcomes.forEach((o) => { + const mid = mermaidId(o.id); + lines.push(` ${mid}["${o.label}"]`); + }); + lines.push(` end`); + + // Add all edges from factors to scenarios + factors.forEach((f) => { + const edges = getEdgesFrom(f.id); + edges.forEach((edge) => { + const sourceId = mermaidId(edge.source); + const targetId = mermaidId(edge.target); + const label = + edge.effect === "increases" + ? "increases" + : edge.effect === "decreases" + ? "decreases" + : ""; + if (label && !simplified) { + lines.push(` ${sourceId} -->|${label}| ${targetId}`); + } else { + lines.push(` ${sourceId} --> ${targetId}`); + } + }); + }); + + // Add edges from scenarios to outcomes + scenarios.forEach((s) => { + const edges = getEdgesFrom(s.id); + edges.forEach((edge) => { + const sourceId = mermaidId(edge.source); + const targetId = mermaidId(edge.target); + const label = + edge.effect === "increases" + ? "increases" + : edge.effect === "decreases" + ? "decreases" + : ""; + if (label && !simplified) { + lines.push(` ${sourceId} -->|${label}| ${targetId}`); + } else { + lines.push(` ${sourceId} --> ${targetId}`); + } + }); + }); + + // Styles + scenarios.forEach((s) => { + const mid = mermaidId(s.id); + lines.push(` style ${mid} fill:#ffe66d`); + }); + outcomes.forEach((o) => { + const mid = mermaidId(o.id); + lines.push(` style ${mid} fill:#ff6b6b`); + }); + + const chart = lines.join("\n"); + + return ; +} + +export default FactorRelationshipDiagram; diff --git a/apps/longterm-next/src/components/wiki/FactorScope.tsx b/apps/longterm-next/src/components/wiki/FactorScope.tsx new file mode 100644 index 00000000..1793aa50 --- /dev/null +++ b/apps/longterm-next/src/components/wiki/FactorScope.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { getSubItemScope } from "@/data/parameter-graph-data"; + +interface FactorScopeProps { + nodeId?: string; + subItemLabel?: string; + scope?: string; + title?: string; +} + +export function FactorScope({ + nodeId, + subItemLabel, + scope: directScope, + title = "Scope", +}: FactorScopeProps) { + const scope = + directScope || + (nodeId && subItemLabel + ? getSubItemScope(nodeId, subItemLabel) + : undefined); + + if (!scope) { + return null; + } + + // Parse scope into includes/excludes + const lines = scope.split("\n").filter((line) => line.trim()); + const includes: string[] = []; + const excludes: string[] = []; + let currentSection: "includes" | "excludes" | null = null; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.toLowerCase().startsWith("includes:")) { + currentSection = "includes"; + const content = trimmed.slice("includes:".length).trim(); + if (content) includes.push(content); + } else if (trimmed.toLowerCase().startsWith("excludes:")) { + currentSection = "excludes"; + const content = trimmed.slice("excludes:".length).trim(); + if (content) excludes.push(content); + } else if (currentSection === "includes") { + includes.push(trimmed); + } else if (currentSection === "excludes") { + excludes.push(trimmed); + } + } + + return ( +
+
+ {title} + + from YAML + +
+
+ {includes.length > 0 && ( +
+ Includes: +
    + {includes.map((item, i) => ( +
  • {item}
  • + ))} +
+
+ )} + {excludes.length > 0 && ( +
+ Excludes: +
    + {excludes.map((item, i) => ( +
  • {item}
  • + ))} +
+
+ )} +
+
+ ); +} + +export default FactorScope; diff --git a/apps/longterm-next/src/data/facts.ts b/apps/longterm-next/src/data/facts.ts new file mode 100644 index 00000000..352c54a2 --- /dev/null +++ b/apps/longterm-next/src/data/facts.ts @@ -0,0 +1,445 @@ +/** + * Fact processing module for longterm-next. + * + * Reads canonical fact YAML files from src/data/facts/, parses numeric values, + * resolves computed expressions, and exports the processed fact store. + * + * This runs at build time / server-component level only (uses fs). + * Ported from apps/longterm/scripts/build-data.mjs. + */ + +import fs from "fs"; +import path from "path"; +import yaml from "js-yaml"; +import type { Fact } from "./index"; + +// ============================================================================ +// YAML LOADING +// ============================================================================ + +const FACTS_DIR = path.resolve(process.cwd(), "src/data/facts"); + +interface RawFactFile { + entity: string; + facts: Record< + string, + { + value?: string; + numeric?: number; + asOf?: string; + source?: string; + note?: string; + noCompute?: boolean; + compute?: string; + format?: string; + formatDivisor?: number; + } + >; +} + +// ============================================================================ +// NUMERIC PARSING +// ============================================================================ + +/** + * Auto-parse a numeric value from a human-readable string. + * Returns null if the string can't be reliably parsed. + * + * Examples: + * "$350 billion" -> 350_000_000_000 + * "$13 billion" -> 13_000_000_000 + * "100 million" -> 100_000_000 + * "$76,001/year" -> 76001 + * "40%" -> 0.4 + * "83%" -> 0.83 + */ +function parseNumericValue(value: string): number | null { + if (!value || typeof value !== "string") return null; + + // Skip ranges and ambiguous values + if (value.includes(" to ") || (value.includes("-") && value.match(/\d+-\d/))) + return null; + if (value.includes("+") && !value.startsWith("+")) return null; + + const s = value.trim(); + + // Percentage: "40%" -> 0.4 + const pctMatch = s.match(/^(\d+(?:\.\d+)?)%$/); + if (pctMatch) return parseFloat(pctMatch[1]) / 100; + + // Dollar + number + unit: "$13 billion", "$3.4 million" + const dollarUnitMatch = s.match( + /^\$?([\d,.]+)\s*(billion|million|trillion|thousand)?\s*(?:\/\w+)?$/i + ); + if (dollarUnitMatch) { + const num = parseFloat(dollarUnitMatch[1].replace(/,/g, "")); + if (isNaN(num)) return null; + const unit = (dollarUnitMatch[2] || "").toLowerCase(); + const multipliers: Record = { + trillion: 1e12, + billion: 1e9, + million: 1e6, + thousand: 1e3, + "": 1, + }; + return num * (multipliers[unit] || 1); + } + + // Plain number with possible commas: "1,900" + const plainMatch = s.match(/^[\d,]+(?:\.\d+)?$/); + if (plainMatch) { + return parseFloat(s.replace(/,/g, "")); + } + + return null; +} + +// ============================================================================ +// EXPRESSION EVALUATION (recursive descent, no eval) +// ============================================================================ + +interface Token { + type: "op" | "num"; + value: string | number; +} + +/** + * Safe expression evaluator for computed facts. + * Supports: numbers, +, -, *, /, parentheses, and {entity.factId} references. + */ +function evaluateExpression( + expression: string, + facts: Record +): number { + // Replace {entity.factId} references with numeric values + const resolved = expression.replace(/\{([^}]+)\}/g, (_match, ref) => { + const fact = facts[ref]; + if (!fact) throw new Error(`Unknown fact reference: ${ref}`); + if (fact.noCompute) + throw new Error( + `Fact ${ref} is marked noCompute (not a computable quantity)` + ); + if (fact.numeric == null) + throw new Error(`Fact ${ref} has no numeric value`); + return String(fact.numeric); + }); + + // Tokenize + const tokens: Token[] = []; + let i = 0; + while (i < resolved.length) { + if (/\s/.test(resolved[i])) { + i++; + continue; + } + if ("+-*/()".includes(resolved[i])) { + tokens.push({ type: "op", value: resolved[i] }); + i++; + } else if (/[\d.]/.test(resolved[i])) { + let num = ""; + while (i < resolved.length && /[\d.eE]/.test(resolved[i])) { + num += resolved[i]; + i++; + } + // Handle signed exponent (e.g., 3.5e+12) + if ( + /[eE]$/.test(num) && + i < resolved.length && + (resolved[i] === "+" || resolved[i] === "-") + ) { + num += resolved[i]; + i++; + while (i < resolved.length && /\d/.test(resolved[i])) { + num += resolved[i]; + i++; + } + } + tokens.push({ type: "num", value: parseFloat(num) }); + } else { + throw new Error( + `Unexpected character in expression: "${resolved[i]}" at position ${i}` + ); + } + } + + // Recursive descent parser + let pos = 0; + function peek(): Token | undefined { + return tokens[pos]; + } + function consume(expected?: string): Token { + const t = tokens[pos++]; + if (expected && (t?.type !== "op" || t?.value !== expected)) { + throw new Error(`Expected "${expected}" but got "${t?.value}"`); + } + return t; + } + + function parseExpr(): number { + let left = parseTerm(); + while ( + peek()?.type === "op" && + (peek()!.value === "+" || peek()!.value === "-") + ) { + const op = consume().value; + const right = parseTerm(); + left = op === "+" ? left + right : left - right; + } + return left; + } + + function parseTerm(): number { + let left = parseFactor(); + while ( + peek()?.type === "op" && + (peek()!.value === "*" || peek()!.value === "/") + ) { + const op = consume().value; + const right = parseFactor(); + if (op === "/") { + if (right === 0) throw new Error("Division by zero"); + left = left / right; + } else { + left = left * right; + } + } + return left; + } + + function parseFactor(): number { + const t = peek(); + if (!t) throw new Error("Unexpected end of expression"); + if (t.type === "num") { + pos++; + return t.value as number; + } + if (t.type === "op" && t.value === "(") { + consume("("); + const val = parseExpr(); + consume(")"); + return val; + } + if (t.type === "op" && t.value === "-") { + consume(); + return -parseFactor(); + } + throw new Error(`Unexpected token: ${JSON.stringify(t)}`); + } + + const result = parseExpr(); + if (pos < tokens.length) { + throw new Error( + `Unexpected tokens after expression: ${tokens + .slice(pos) + .map((t) => t.value) + .join(" ")}` + ); + } + return result; +} + +// ============================================================================ +// COMPUTED FACT FORMATTING +// ============================================================================ + +/** + * Check if a compute expression references any currency-denominated facts. + */ +function isCurrencyExpression( + expression: string, + facts: Record +): boolean { + const refRegex = /\{([^}]+)\}/g; + let m; + while ((m = refRegex.exec(expression)) !== null) { + const fact = facts[m[1]]; + if (fact?.value && fact.value.trim().startsWith("$")) return true; + } + return false; +} + +/** + * Format a computed numeric value for display. + */ +function formatComputedValue( + numeric: number, + format?: string, + formatDivisor?: number, + isCurrency = false +): string { + if (!isFinite(numeric)) + throw new Error( + `Computed value is ${numeric} (expected a finite number)` + ); + const displayNum = formatDivisor ? numeric / formatDivisor : numeric; + + if (!format) { + const prefix = isCurrency ? "$" : ""; + const n = displayNum; + if (Math.abs(n) >= 1e12) return `${prefix}${(n / 1e12).toFixed(1)} trillion`; + if (Math.abs(n) >= 1e9) return `${prefix}${(n / 1e9).toFixed(1)} billion`; + if (Math.abs(n) >= 1e6) return `${prefix}${(n / 1e6).toFixed(1)} million`; + return isCurrency + ? `${prefix}${n.toLocaleString("en-US")}` + : n.toLocaleString("en-US"); + } + + // Simple printf-style: replace %.Nf with the formatted number + return format.replace(/%(?:\.(\d+))?f/, (_, decimals) => { + const d = decimals ? parseInt(decimals) : 0; + return displayNum.toFixed(d); + }); +} + +// ============================================================================ +// RESOLVE COMPUTED FACTS (topological order) +// ============================================================================ + +function resolveComputedFacts(facts: Record): number { + const computed = Object.entries(facts).filter(([, f]) => f.compute); + if (computed.length === 0) return 0; + + // Extract dependencies + const deps = new Map(); + for (const [key, fact] of computed) { + const refs: string[] = []; + const refRegex = /\{([^}]+)\}/g; + let m; + while ((m = refRegex.exec(fact.compute!)) !== null) { + refs.push(m[1]); + } + deps.set(key, refs); + } + + // Topological sort (Kahn's algorithm) + const inDegree = new Map(); + const graph = new Map(); + for (const [key] of deps) { + inDegree.set(key, 0); + graph.set(key, []); + } + for (const [key, refKeys] of deps) { + for (const ref of refKeys) { + if (deps.has(ref)) { + graph.get(ref)!.push(key); + inDegree.set(key, (inDegree.get(key) || 0) + 1); + } + } + } + + const queue: string[] = []; + for (const [key, deg] of inDegree) { + if (deg === 0) queue.push(key); + } + + const order: string[] = []; + while (queue.length > 0) { + const current = queue.shift()!; + order.push(current); + for (const dependent of graph.get(current) || []) { + inDegree.set(dependent, inDegree.get(dependent)! - 1); + if (inDegree.get(dependent) === 0) queue.push(dependent); + } + } + + if (order.length !== computed.length) { + const missing = computed + .map(([k]) => k) + .filter((k) => !order.includes(k)); + throw new Error( + `Circular dependency in computed facts: ${missing.join(", ")}` + ); + } + + // Evaluate in order + let resolved = 0; + for (const key of order) { + const fact = facts[key]; + try { + const numeric = evaluateExpression(fact.compute!, facts); + fact.numeric = numeric; + const currency = isCurrencyExpression(fact.compute!, facts); + fact.value = formatComputedValue( + numeric, + fact.format, + fact.formatDivisor, + currency + ); + fact.computed = true; + resolved++; + } catch (err) { + console.warn( + `[facts] Failed to compute ${key}: ${err instanceof Error ? err.message : err}` + ); + } + } + + return resolved; +} + +// ============================================================================ +// LOAD ALL FACTS +// ============================================================================ + +let _factsStore: Record | null = null; + +/** + * Load and process all canonical facts from YAML files. + * Results are cached in memory for the lifetime of the process. + */ +export function loadFacts(): Record { + if (_factsStore) return _factsStore; + + const facts: Record = {}; + + if (!fs.existsSync(FACTS_DIR)) { + console.warn(`[facts] Facts directory not found: ${FACTS_DIR}`); + _factsStore = facts; + return facts; + } + + const factFiles = fs + .readdirSync(FACTS_DIR) + .filter((f) => f.endsWith(".yaml")); + + for (const file of factFiles) { + const filepath = path.join(FACTS_DIR, file); + const content = fs.readFileSync(filepath, "utf-8"); + const parsed = yaml.load(content) as RawFactFile | null; + + if (parsed?.entity && parsed?.facts) { + for (const [factId, factData] of Object.entries(parsed.facts)) { + const key = `${parsed.entity}.${factId}`; + facts[key] = { + ...factData, + entity: parsed.entity, + factId, + }; + } + } + } + + // Auto-parse numeric values from value strings where not explicitly set + for (const [, fact] of Object.entries(facts)) { + if (fact.numeric == null && fact.value && !fact.compute) { + const parsed = parseNumericValue(fact.value); + if (parsed !== null) { + fact.numeric = parsed; + } + } + } + + // Evaluate computed facts in topological order + const computedCount = resolveComputedFacts(facts); + if (computedCount > 0) { + console.log( + `[facts] Loaded ${Object.keys(facts).length} facts (${computedCount} computed) from ${factFiles.length} files` + ); + } + + _factsStore = facts; + return facts; +} + +// Re-export parseNumericValue for testing +export { parseNumericValue }; diff --git a/apps/longterm-next/src/data/facts/anthropic.yaml b/apps/longterm-next/src/data/facts/anthropic.yaml new file mode 100644 index 00000000..526ae602 --- /dev/null +++ b/apps/longterm-next/src/data/facts/anthropic.yaml @@ -0,0 +1,54 @@ +entity: anthropic +facts: + valuation: + value: "$350 billion" + numeric: 350000000000 + asOf: "2026-01" + + revenue-arr-2025: + value: "$9 billion" + numeric: 9000000000 + asOf: "2025-12" + note: "Annualized run rate at end of 2025" + + revenue-2026-guidance: + value: "$20-26 billion" + asOf: "2026-01" + + gross-margin: + value: "40%" + asOf: "2025" + + enterprise-retention-rate: + value: "88%" + asOf: "2025" + + industry-avg-retention: + value: "76%" + asOf: "2025" + + business-customers: + value: "300,000+" + asOf: "2025" + + customer-concentration: + value: "25%" + asOf: "2025" + note: "Revenue share from top customers (Cursor, GitHub Copilot)" + + interpretability-team-size: + value: "40-60" + asOf: "2025" + + safety-researchers: + value: "200-330" + asOf: "2025" + + safety-pct-of-technical: + value: "20-30%" + asOf: "2025" + + breakeven-target: + value: "2028" + noCompute: true + note: "Projected breakeven year" diff --git a/apps/longterm-next/src/data/facts/jaan-tallinn.yaml b/apps/longterm-next/src/data/facts/jaan-tallinn.yaml new file mode 100644 index 00000000..88ab1ced --- /dev/null +++ b/apps/longterm-next/src/data/facts/jaan-tallinn.yaml @@ -0,0 +1,39 @@ +entity: jaan-tallinn +facts: + anthropic-ownership-low: + value: "~1.2%" + numeric: 0.012 + asOf: "2026-01" + note: "Conservative estimate of remaining Anthropic stake after dilution through 16 rounds and possible secondary sales (0.6-1.7% range midpoint)" + + anthropic-ownership-high: + value: "~2.8%" + numeric: 0.028 + asOf: "2026-01" + note: "Optimistic estimate assuming full share retention through dilution (1.5-4% range midpoint)" + + anthropic-stake-low: + compute: "{anthropic.valuation} * {jaan-tallinn.anthropic-ownership-low}" + format: "$%.1f billion" + formatDivisor: 1000000000 + asOf: "2026-01" + note: "Computed: Anthropic valuation x low ownership estimate" + + anthropic-stake-high: + compute: "{anthropic.valuation} * {jaan-tallinn.anthropic-ownership-high}" + format: "$%.1f billion" + formatDivisor: 1000000000 + asOf: "2026-01" + note: "Computed: Anthropic valuation x high ownership estimate" + + net-worth-estimate: + value: "$900 million" + numeric: 900000000 + asOf: "2019" + source: "https://en.wikipedia.org/wiki/Jaan_Tallinn" + note: "Forbes/Wikipedia estimate from 2019; likely $3-10B+ as of 2026 when accounting for Anthropic stake appreciation, crypto gains, and private investments" + + skype-cofounder: + value: "2003" + noCompute: true + note: "Co-founded Skype, sold to eBay for $2.6B in 2005" diff --git a/apps/longterm-next/src/data/facts/openai.yaml b/apps/longterm-next/src/data/facts/openai.yaml new file mode 100644 index 00000000..8c268f52 --- /dev/null +++ b/apps/longterm-next/src/data/facts/openai.yaml @@ -0,0 +1,57 @@ +entity: openai +facts: + microsoft-total-investment: + value: "$13 billion" + numeric: 13000000000 + asOf: "2024-01" + source: "https://blogs.microsoft.com/blog/2023/01/23/microsoftandopenai/" + + chatgpt-users-first-2-months: + value: "100 million" + asOf: "2023-02" + note: "Fastest-growing consumer application in history at that time" + + valuation-2024: + value: "$157 billion+" + numeric: 157000000000 + asOf: "2024-12" + note: "October 2024 funding round valuation" + + valuation-2025: + value: "$500 billion+" + numeric: 500000000000 + asOf: "2025" + note: "Approximate valuation in 2025 private market transactions" + + revenue-arr-2025: + value: "$20 billion" + numeric: 20000000000 + asOf: "2025" + note: "Annualized run rate referenced in Anthropic valuation comparisons" + + gpt3-parameters: + value: "175 billion" + asOf: "2020-06" + + revenue-2024-projected: + value: "$3.4 billion" + asOf: "2024-10" + + revenue-yoy-growth-2024: + value: "1,700%" + asOf: "2024" + + cofounder-departure-rate: + value: "75%" + asOf: "2024" + note: "75% of co-founders departed within 9 years of founding" + + o1-aime-score: + value: "83%" + asOf: "2024-09" + note: "o1 model performance on AIME math competition" + + o1-swe-bench-score: + value: "71.7%" + asOf: "2024-09" + note: "o1 model on SWE-bench Verified" diff --git a/apps/longterm-next/src/data/facts/sam-altman.yaml b/apps/longterm-next/src/data/facts/sam-altman.yaml new file mode 100644 index 00000000..c6d81374 --- /dev/null +++ b/apps/longterm-next/src/data/facts/sam-altman.yaml @@ -0,0 +1,40 @@ +entity: sam-altman +facts: + net-worth: + value: "$2.8 billion" + asOf: "2024" + + openai-salary: + value: "$76,001/year" + asOf: "2024" + + loopt-sale-price: + value: "$43 million" + note: "Sold to Green Dot Corporation" + + early-stripe-investment: + value: "$15,000 for 2%" + asOf: "2009" + + yc-companies-funded: + value: "1,900" + note: "Companies funded during YC tenure" + + board-crisis-employee-letter: + value: "95%" + asOf: "2023-11" + note: "738 of 770 employees signed letter threatening to quit" + + worldcoin-users: + value: "26 million" + asOf: "2024" + note: "Total users on Worldcoin network" + + worldcoin-verified: + value: "12 million" + asOf: "2024" + note: "Verified Worldcoin users (via orb)" + + helion-personal-investment: + value: "$375 million" + note: "Personal investment in Helion Energy" diff --git a/apps/longterm-next/src/data/index.ts b/apps/longterm-next/src/data/index.ts index 96266ab1..5eab1be9 100644 --- a/apps/longterm-next/src/data/index.ts +++ b/apps/longterm-next/src/data/index.ts @@ -26,6 +26,7 @@ import { isOrganization, isPolicy, } from "./entity-schemas"; +import { loadFacts } from "./facts"; // Path to the local data directory (database.json is synced here from longterm) const LOCAL_DATA_DIR = path.resolve(process.cwd(), "src/data"); @@ -698,12 +699,12 @@ export function getBacklinksFor( } // ============================================================================ -// CANONICAL FACTS +// CANONICAL FACTS (loaded from local YAML files in src/data/facts/) // ============================================================================ export function getFact(entityId: string, factId: string): Fact | undefined { - const db = getDatabase(); - return db.facts?.[`${entityId}.${factId}`]; + const facts = loadFacts(); + return facts[`${entityId}.${factId}`]; } export function getFactValue(entityId: string, factId: string): string | undefined { @@ -711,9 +712,9 @@ export function getFactValue(entityId: string, factId: string): string | undefin } export function getFactsForEntity(entityId: string): Record { - const db = getDatabase(); + const facts = loadFacts(); const result: Record = {}; - for (const [key, fact] of Object.entries(db.facts || {})) { + for (const [, fact] of Object.entries(facts)) { if (fact.entity === entityId) { result[fact.factId] = fact; } @@ -722,8 +723,8 @@ export function getFactsForEntity(entityId: string): Record { } export function getAllFacts(): Array { - const db = getDatabase(); - return Object.entries(db.facts || {}).map(([key, fact]) => ({ + const facts = loadFacts(); + return Object.entries(facts).map(([key, fact]) => ({ ...fact, key, })); diff --git a/apps/longterm-next/src/lib/__tests__/validate-content.test.ts b/apps/longterm-next/src/lib/__tests__/validate-content.test.ts index 1c2a0145..cbfb79a5 100644 --- a/apps/longterm-next/src/lib/__tests__/validate-content.test.ts +++ b/apps/longterm-next/src/lib/__tests__/validate-content.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect } from "vitest"; import fs from "fs"; import path from "path"; +import yaml from "js-yaml"; // --------------------------------------------------------------------------- // Find and pre-read all MDX files @@ -389,3 +390,153 @@ describe("markdown-lists", () => { ).toHaveLength(0); }); }); + +// --------------------------------------------------------------------------- +// Rule 7: Fact Consistency +// Ported from apps/longterm/scripts/lib/rules/fact-consistency.mjs +// --------------------------------------------------------------------------- + +describe("fact-consistency", () => { + // Values that are too short or too generic to search for reliably + const MIN_VALUE_LENGTH = 5; + const GENERIC_VALUES = new Set([ + "2025", "2026", "2027", "2028", "2029", "2030", + "25%", "40%", "50%", "75%", "20%", "30%", "10%", + ]); + + interface CanonicalFact { + entity: string; + factId: string; + key: string; + value?: string; + asOf?: string; + } + + function loadCanonicalFacts(): CanonicalFact[] { + const factsDir = path.resolve(__dirname, "../../data/facts"); + const facts: CanonicalFact[] = []; + + if (!fs.existsSync(factsDir)) return facts; + + const files = fs.readdirSync(factsDir).filter((f) => f.endsWith(".yaml")); + for (const file of files) { + const filepath = path.join(factsDir, file); + const content = fs.readFileSync(filepath, "utf-8"); + const parsed = yaml.load(content) as { entity: string; facts: Record } | null; + if (parsed?.entity && parsed?.facts) { + for (const [factId, factData] of Object.entries(parsed.facts)) { + facts.push({ + entity: parsed.entity, + factId, + key: `${parsed.entity}.${factId}`, + value: factData.value, + asOf: factData.asOf, + }); + } + } + } + + return facts; + } + + function generateSearchPatterns(value: string): Array<{ regex: RegExp; isExact: boolean }> { + if (value.length < MIN_VALUE_LENGTH && !value.startsWith("$")) return []; + if (GENERIC_VALUES.has(value)) return []; + + const patterns: Array<{ regex: RegExp; isExact: boolean }> = []; + + // Direct match with optional backslash before $ + let escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + escaped = escaped.replace(/\\\$/g, "\\\\?\\$"); + patterns.push({ regex: new RegExp(escaped, "gi"), isExact: true }); + + // Dollar amount abbreviation variations: "$13 billion" -> "$13B" + const dollarMatch = value.match(/^\$?([\d,.]+)\s*(billion|million|trillion|thousand)/i); + if (dollarMatch) { + const num = dollarMatch[1]; + const unit = dollarMatch[2].toLowerCase(); + const abbrevMap: Record = { billion: "B", million: "M", trillion: "T", thousand: "K" }; + const abbr = abbrevMap[unit]; + if (abbr) { + const escapedNum = num.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + patterns.push({ + regex: new RegExp(`\\\\?\\$${escapedNum}\\s*${abbr}\\b`, "gi"), + isExact: true, + }); + } + } + + return patterns; + } + + function isInsideFComponent(body: string, matchIndex: number): boolean { + const before = body.slice(Math.max(0, matchIndex - 200), matchIndex); + const after = body.slice(matchIndex, Math.min(body.length, matchIndex + 200)); + + const lastOpenF = before.lastIndexOf(""); + if (closingBracket === -1) return true; + if (afterOpen[closingBracket - 1] === "/") return false; + + return after.includes(""); + } + + it("hardcoded fact values should use component", () => { + const facts = loadCanonicalFacts(); + if (facts.length === 0) return; + + const violations: Violation[] = []; + + for (const fact of facts) { + if (!fact.value) continue; + const patterns = generateSearchPatterns(fact.value); + + for (const { file, lines, bodyStart, inCodeBlock } of allFiles) { + if (isInternalPage(file)) continue; + const body = lines.join("\n"); + + for (const { regex } of patterns) { + regex.lastIndex = 0; + let match; + while ((match = regex.exec(body)) !== null) { + if (isInsideFComponent(body, match.index)) continue; + + // Find line number + const beforeMatch = body.slice(0, match.index); + const lineNum = (beforeMatch.match(/\n/g) || []).length + 1; + + // Skip if in code block or frontmatter + if (lineNum - 1 < bodyStart) continue; + if (inCodeBlock[lineNum - 1]) continue; + if (isInInlineCode(lines[lineNum - 1] || "", match.index - (beforeMatch.lastIndexOf("\n") + 1))) continue; + + violations.push({ + file, + line: lineNum, + message: `Hardcoded "${match[0]}" matches fact ${fact.key}${fact.asOf ? ` (as of ${fact.asOf})` : ""}. Consider using `, + }); + } + } + } + } + + // Deduplicate: one issue per file+line+fact + const seen = new Set(); + const unique = violations.filter((v) => { + const factKeyMatch = v.message.match(/matches fact ([\w.-]+)/); + const factKey = factKeyMatch ? factKeyMatch[1] : ""; + const key = `${v.file}:${v.line}:${factKey}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + // This is informational — log the findings but don't fail the test + if (unique.length > 0) { + console.log(formatViolations("Fact consistency (info)", unique)); + } + }); +});