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
+
+
+
+
+
+
+ | Debate |
+ Core Question |
+
+
+
+ {debates.map((debate, i) => (
+
+ | {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
+
+
+
+
+
+
+ | Metric |
+ Score |
+ Interpretation |
+
+
+
+ {metrics.map(({ key, label }) => {
+ const value = ratings[key];
+ if (value === undefined) return null;
+ return (
+
+ | {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 (
+
+ );
+}
+
+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));
+ }
+ });
+});