diff --git a/.github/prompts/11-forward-projection.md b/.github/prompts/11-forward-projection.md index 27fb1f393f4..5d5beadf382 100644 --- a/.github/prompts/11-forward-projection.md +++ b/.github/prompts/11-forward-projection.md @@ -67,13 +67,22 @@ Read every artifact word-by-word. Apply the AI-First Quality Principle For `term-outlook` and `election-cycle` (where `SCENARIO_MAX_MONTHS ≥ 36`), [`scenario-forecast.md`](../../analysis/templates/scenario-forecast.md) opens its long-horizon-mode header — see -[`strategic-extensions-methodology.md`](../../analysis/methodologies/strategic-extensions-methodology.md) -for the appendix. ≥ 6 scenarios are mandatory, including at least one -regime-change branch when any tripwire from §2 is armed. +[`scenario-forecast.md §0`](../../analysis/templates/scenario-forecast.md#0️⃣-long-horizon-mode) +for the full specification. Key requirements in long-horizon mode: + +- **≥ 6 scenarios** are mandatory (enforced by Stage-C validator on `term-outlook`/`election-cycle`). +- Mandatory **EP-election outcome branch** with three sub-paths: centre-right majority, centre-left majority, fragmented coalition. +- Mandatory **regime-change branch**; use tripwires from §2 to define and monitor the branch. +- At least **2 wildcard/black-swan branches**. +- Per-scenario WEP confidence band **drawn from the decay table** at [`forward-projection-methodology.md §3`](../../analysis/methodologies/forward-projection-methodology.md#3-wep-decay-table) — this is the **single source of truth** for horizon-conditional WEP numbers; do not copy the table into `scenario-forecast.md`. + +**Forward indicators in long-horizon mode:** [`forward-indicators.md`](../../analysis/templates/forward-indicators.md) includes a **Multi-Horizon Decay Table** (§Multi-Horizon Decay Table) that maps every indicator type to the horizons at which it retains predictive signal. Ensure every indicator card carries a `**Horizons:**` tag so monitoring plans retire indicators at their decay horizon. Canonical WEP decay numbers are in [`forward-projection-methodology.md §3`](../../analysis/methodologies/forward-projection-methodology.md#3-wep-decay-table) — `forward-indicators.md` references but does not duplicate them. ## 6 · Cross-References -- [`forward-projection-methodology.md`](../../analysis/methodologies/forward-projection-methodology.md) — protocol authority. +- [`forward-projection-methodology.md`](../../analysis/methodologies/forward-projection-methodology.md) — **protocol authority and canonical WEP decay table (§3)**. +- [`scenario-forecast.md §0`](../../analysis/templates/scenario-forecast.md#0️⃣-long-horizon-mode) — long-horizon-mode specification. +- [`forward-indicators.md §Multi-Horizon Decay Table`](../../analysis/templates/forward-indicators.md#-multi-horizon-decay-table) — horizon tags per indicator type. - [`02-analysis-protocol.md`](02-analysis-protocol.md) — 2-pass iterative improvement. - [`03-analysis-completeness-gate.md`](03-analysis-completeness-gate.md) — Stage-C gate definition. - [`04-article-generation.md`](04-article-generation.md) — how the artifacts in §1 land in the rendered article. diff --git a/analysis/methodologies/reference-quality-thresholds.json b/analysis/methodologies/reference-quality-thresholds.json index abb52b3f008..1965c34c0d3 100644 --- a/analysis/methodologies/reference-quality-thresholds.json +++ b/analysis/methodologies/reference-quality-thresholds.json @@ -48,7 +48,13 @@ "date": "2026-04-18" }, "structuralRequirements": { - "note": "Structural contracts enforced by scripts/validate-analysis-completeness.js. mermaidRequired = artifacts that MUST contain at least one Mermaid block (in addition to the directory-based default for intelligence/, classification/, risk-scoring/, threat-assessment/). readerBlockRequired = artifacts that MUST contain a Reader Briefing / 'For Citizens' / 'Plain Language' section. sourceDiversityRequired = artifacts that MUST cite at least one EP MCP tool reference OR carry an evidence table (markdown table with a Source / Evidence / Reference header column, e.g. a 'Data Sources & Provenance' block); the validator accepts either signal. requiredSections = artifact -> array of H2 substrings that MUST appear (case-insensitive substring match). Added 2026-04 alongside template v2.0 enrichment of impact-matrix, forces-analysis, actor-mapping, political-capital-risk, actor-threat-profiles, legislative-disruption, legislative-velocity-risk, consequence-trees.", + "note": "Structural contracts enforced by scripts/validate-analysis-completeness.js. mermaidRequired = artifacts that MUST contain at least one Mermaid block (in addition to the directory-based default for intelligence/, classification/, risk-scoring/, threat-assessment/). readerBlockRequired = artifacts that MUST contain a Reader Briefing / 'For Citizens' / 'Plain Language' section. sourceDiversityRequired = artifacts that MUST cite at least one EP MCP tool reference OR carry an evidence table (markdown table with a Source / Evidence / Reference header column, e.g. a 'Data Sources & Provenance' block); the validator accepts either signal. requiredSections = artifact -> array of H2 substrings that MUST appear (case-insensitive substring match). Added 2026-04 alongside template v2.0 enrichment of impact-matrix, forces-analysis, actor-mapping, political-capital-risk, actor-threat-profiles, legislative-disruption, legislative-velocity-risk, consequence-trees. longHorizonScenarioGate = article types whose intelligence/scenario-forecast.md MUST contain >= 6 scenario headings (### Scenario N pattern); activated for manifest.articleType values listed in longHorizonScenarioGate.articleTypes. Added 2026-05 alongside scenario-forecast.md template v1.3.", + "longHorizonScenarioGate": { + "articleTypes": ["term-outlook", "election-cycle"], + "minScenarios": 6, + "artifact": "intelligence/scenario-forecast.md", + "note": "Enforced by scripts/validate-analysis-completeness.js countScenarios(). Counts all headings matching /^###\\s+Scenario\\s+[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*\\s*[:—]/gm in the artifact (supports hyphenated IDs like A-24; underscores excluded). Activated for manifest.articleType values listed in articleTypes above." + }, "mermaidRequired": [ "classification/impact-matrix.md", "classification/forces-analysis.md", diff --git a/analysis/templates/forward-indicators.md b/analysis/templates/forward-indicators.md index 6c8293f4577..18b41a119d6 100644 --- a/analysis/templates/forward-indicators.md +++ b/analysis/templates/forward-indicators.md @@ -11,6 +11,49 @@ --- +## 🕐 Multi-Horizon Decay Table + +**Canonical decay numbers live in [`forward-projection-methodology.md §3`](../methodologies/forward-projection-methodology.md#3-wep-decay-table).** The table below shows *which indicator types remain predictively useful at each horizon* — it does NOT replicate WEP probability values. Always read `forward-projection-methodology.md §3` as the authoritative source for the numbers themselves. + +Each indicator is tagged with the horizons at which it retains meaningful predictive signal (`✅ active`) vs. the horizons where it has decayed beyond utility (`⬜ decayed`). + +```markdown +## Multi-Horizon Decay Table + +> WEP decay values are sourced from: [forward-projection-methodology.md §3](../methodologies/forward-projection-methodology.md#3-wep-decay-table). +> Do NOT duplicate or override those numbers here. + +| Indicator Type | Example | T+7d | T+30d | T+90d | T+12m | T+term-end | T+EP-election | +|---|---|:---:|:---:|:---:|:---:|:---:|:---:| +| **Procedural / calendar** | Committee vote date, rapporteur deadline, trilogue round | ✅ | ✅ | ⬜ | ⬜ | ⬜ | ⬜ | +| **Coalition cohesion** | EPP/S&D alignment rate on roll-call votes | ✅ | ✅ | ✅ | ⬜ | ⬜ | ⬜ | +| **Legislative pipeline** | Procedure stage, report adoption, trilogue conclusion | ✅ | ✅ | ✅ | ⬜ | ⬜ | ⬜ | +| **Policy-position shift** | MEP speech-content pivot, amendment filing pattern | ✅ | ✅ | ✅ | ⬜ | ⬜ | ⬜ | +| **National-government change** | Election results, coalition formation in MS | ⬜ | ✅ | ✅ | ✅ | ✅ | ✅ | +| **Macro-economic signal** | IMF WEO growth revision, fiscal-deficit threshold | ⬜ | ✅ | ✅ | ✅ | ✅ | ✅ | +| **EP-group composition drift** | Defection rate, new-member absorption pace | ⬜ | ⬜ | ✅ | ✅ | ✅ | ✅ | +| **Presidential/Commission cycle** | Commission WP priorities, DG-level reshuffles | ⬜ | ⬜ | ✅ | ✅ | ✅ | ✅ | +| **Electoral/polling signal** | Eurobarometer party-preference trend, national polling | ⬜ | ⬜ | ⬜ | ✅ | ✅ | ✅ | +| **Spitzenkandidaten declared** | Lead-candidate announcement, party-congress endorsement | ⬜ | ⬜ | ⬜ | ✅ | ✅ | ✅ | +| **Seat-projection model** | EP seat arithmetic based on national polls | ⬜ | ⬜ | ⬜ | ⬜ | ✅ | ✅ | +| **Treaty-revision / IGC signal** | IGC announcement, Art. 48 TEU trigger | ⬜ | ⬜ | ⬜ | ⬜ | ✅ | ✅ | + +**Legend:** ✅ active (indicator retains predictive signal) | ⬜ decayed (signal below useful threshold at this horizon) +``` + +### Per-indicator horizon tagging + +When populating Section 3 (Indicator Detail Cards), **tag every indicator** with its active horizons using the `**Horizons:**` field (see card template in §3). This allows monitoring plans to silently retire indicators at their decay horizon rather than reporting stale signals. + +**Horizon tag examples:** +- `rapporteur-deadline` → horizons: `7d / 30d` +- `EPP-S&D alignment rate` → horizons: `7d / 30d / 90d` +- `Spitzenkandidaten declared candidates` → horizons: `12m / term-end / EP-election` +- `Eurobarometer party-preference trend` → horizons: `12m / term-end / EP-election` +- `National-government election result` → horizons: `30d / 90d / 12m / term-end / EP-election` + +--- + ## 📋 Header Block ```markdown @@ -42,7 +85,7 @@ ## 📊 Section 2 — Indicator Master Table -**Required:** ≥10 indicators with all 7 structure fields. +**Required:** ≥10 indicators with all 8 structure fields. ```markdown ## Indicator Inventory @@ -72,6 +115,9 @@ **Category:** Political | Institutional | Policy | Electoral | External | Crisis +**Horizons:** {comma-separated list from: 7d / 30d / 90d / 12m / term-end / EP-election} +*(See Multi-Horizon Decay Table above for decay rules; canonical WEP bands per horizon in [forward-projection-methodology.md §3](../methodologies/forward-projection-methodology.md#3-wep-decay-table))* + **Description:** {What this indicator measures and why it matters} **Current State:** @@ -243,7 +289,9 @@ graph TB ## ✅ Quality Checklist - [ ] ≥10 indicators defined -- [ ] Each indicator has all 7 structure fields +- [ ] Each indicator has all 8 structure fields (including required `**Horizons:**` tag) +- [ ] Multi-Horizon Decay Table present in §Multi-Horizon Decay Table +- [ ] Every indicator has the `**Horizons:**` field populated with its active horizons (7d / 30d / 90d / 12m / term-end / EP-election) - [ ] Indicator categories balanced across Political/Institutional/Policy/Electoral/External/Crisis - [ ] Dashboard Mermaid shows all indicators with status - [ ] Scenario linkage explicit for each indicator @@ -251,7 +299,14 @@ graph TB - [ ] EP MCP tool mapping complete - [ ] Historical context provided for key indicators - [ ] Thresholds evidence-based, not arbitrary +- [ ] WEP bands referenced from [`forward-projection-methodology.md §3`](../methodologies/forward-projection-methodology.md#3-wep-decay-table) — NOT duplicated here + +## 🔗 Cross-References + +- [`../methodologies/forward-projection-methodology.md §3`](../methodologies/forward-projection-methodology.md#3-wep-decay-table) — **canonical WEP decay table** and horizon lattice (single source of truth) +- [`scenario-forecast.md §0`](scenario-forecast.md) — long-horizon mode requirements; EP-election branches that this indicator set must support +- [`../methodologies/electoral-domain-methodology.md §Part 6`](../methodologies/electoral-domain-methodology.md#part-6--forward-indicators-forward-indicatorsmd) — full methodology for this artifact --- -*Template version 1.0 — EU Parliament Monitor Forward Indicators* +*Template version 1.1 — EU Parliament Monitor Forward Indicators* diff --git a/analysis/templates/scenario-forecast.md b/analysis/templates/scenario-forecast.md index 277fe4ae46b..8b4b3e1a11d 100644 --- a/analysis/templates/scenario-forecast.md +++ b/analysis/templates/scenario-forecast.md @@ -1,11 +1,51 @@ +--- +long-horizon-mode: false +--- + # 🔮 Scenario Forecast Template — Probability-Weighted Futures -> **📌 Template Instructions:** Copy to `analysis/daily/{date}/{article-type}-run{N}/intelligence/scenario-forecast.md`. Develop 3-5 forward scenarios with probability weights, early-warning indicators, and trigger events. See [methodologies/per-artifact-methodologies.md §scenario-forecast](../methodologies/per-artifact-methodologies.md#scenario-forecast). +> **📌 Template Instructions:** Copy to `analysis/daily/{date}/{article-type}-run{N}/intelligence/scenario-forecast.md`. Develop 3-5 forward scenarios (standard mode) or ≥6 scenarios (long-horizon mode) with probability weights, early-warning indicators, and trigger events. See [methodologies/per-artifact-methodologies.md §scenario-forecast](../methodologies/per-artifact-methodologies.md#scenario-forecast). + +> **🎯 Purpose:** Forward-looking analysis mapping plausible futures for the next 7/30/90 days (standard) or 12m/term-end/EP-election (long-horizon). Each scenario includes probability, narrative, early-warning indicators, trigger events, and stakeholder impact. + +--- + +## 0️⃣ Long-Horizon Mode + +**`long-horizon-mode`** is an optional frontmatter flag that marks a run as long-horizon in the template. Set it to `true` when writing a `term-outlook` or `election-cycle` analysis run. However, for validator-enforced long-horizon article types — **`term-outlook`** and **`election-cycle`** — Stage C still applies the long-horizon rules from `manifest.articleType` even if this flag is unset or `false`. Standard behaviour (3–5 scenarios, 7/30/90-day horizon) is preserved only for article types outside that enforced set. + +### For long-horizon runs — whether triggered by `long-horizon-mode: true` or by validator-enforced `manifest.articleType` — the following requirements override the standard rules: + +| Requirement | Standard mode | Long-horizon mode | +|---|---|---| +| **Minimum scenario count** | ≥ 3 | **≥ 6** | +| **EP-election outcome branch** | Optional | **Mandatory** (centre-right / centre-left / fragmented coalition) | +| **Per-scenario WEP confidence band** | Required | Required — drawn from the decay table in [`forward-projection-methodology.md §3`](../methodologies/forward-projection-methodology.md#3-wep-decay-table) | +| **Regime-change branch** | When any tripwire fires | **Mandatory** (at least 1 dedicated branch) | +| **Wildcard/black-swan branches** | Recommended | **≥ 2 mandatory** | +| **Anti-pattern "5+ scenarios"** | Applies | **Overridden** — cap lifted to ≥ 6 main + residual | + +### Activation signals (checked by Stage-C validator) + +The validator enforces the ≥ 6 scenario floor when the manifest's `articleType` is one of the configured types: **`term-outlook`**, **`election-cycle`**. Registry fields such as `electoralOverlay === true` and `scenarioMaxHorizonMonths >= 36` explain why those types are included, but are not the fields the validator directly inspects. + +### EP-election outcome branches (mandatory in long-horizon mode) + +The three mandatory EP-election branches are: -> **🎯 Purpose:** Forward-looking analysis mapping plausible futures for the next 7/30/90 days. Each scenario includes probability, narrative, early-warning indicators, trigger events, and stakeholder impact. +| Branch | Label | Probability range | +|---|---|---| +| Centre-right majority | `EPP + ECR + ID` ≥ 360 seats | Derive from `seat-projection.md` | +| Centre-left majority | `S&D + RE + Greens/EFA` ≥ 360 seats | Derive from `seat-projection.md` | +| Fragmented coalition | No bloc ≥ 360 — EP-wide grand coalition needed | Often highest probability in long horizon | + +Each EP-election branch **must** carry: +- A WEP band drawn from the `T+EP-election ±6m` row of the decay table (`Highly Unlikely` → `Almost No Chance`; see [`forward-projection-methodology.md §3`](../methodologies/forward-projection-methodology.md#3-wep-decay-table)). +- A reference class from `historical-baseline.md §Electoral baselines` (≥ 3 EP-term analogues). +- ≥ 3 early-warning indicators with ISO-date thresholds. --- @@ -15,8 +55,9 @@ |-------|-------| | **Report ID** | `[REQUIRED: SF-YYYY-MM-DD-runNN]` | | **Analysis Date** | `[REQUIRED: YYYY-MM-DD]` | -| **Horizon** | `[REQUIRED: 7 days / 30 days / 90 days]` | -| **Scenarios Developed** | `[REQUIRED: count 3-5]` | +| **Horizon** | `[REQUIRED: 7 days / 30 days / 90 days / 12m / term-end / EP-election]` | +| **Scenarios Developed** | `[REQUIRED: count 3-5 (standard) or ≥6 (long-horizon)]` | +| **Long-Horizon Mode** | `[REQUIRED for term-outlook and election-cycle articleTypes: true / false]` | | **Confidence** | `[REQUIRED: 🟢/🟡/🔴]` | --- @@ -240,11 +281,14 @@ exclusive. | Probabilities all equal (33/33/33) | "I don't know" disguised | Differentiate; if uniform, cite reasoning | | Scenario without driver | Cannot be tested | Each scenario has ≥1 named causal driver | | Scenario without indicator | Cannot be monitored | Each scenario has ≥3 falsifiable indicators | -| Probabilities outside WEP grid | Non-standard | Use WEP bands: very likely (75-90%), likely (55-75%), possible (35-55%), unlikely (15-35%) | +| Probabilities outside WEP grid | Non-standard | Use WEP bands from [`forward-projection-methodology.md §3`](../methodologies/forward-projection-methodology.md#3-wep-decay-table) | | Update without version-stamp | Loses audit trail | Date-stamp each forecast revision | -| 5+ scenarios | Too granular for human interpretation | Cap at 3-4 main + 1 residual | -| "Black swan" scenario in main 3 | Misclassification | Black swans → `wildcards-blackswans.md` | +| 5+ scenarios (standard mode) | Too granular for human interpretation | Cap at 3-4 main + 1 residual **unless long-horizon-mode active** | +| < 6 scenarios (long-horizon mode) | Insufficient coverage for multi-year horizon | Must have ≥6 including EP-election branch + regime-change + 2 wildcards | +| Missing EP-election branch in long-horizon | Omits dominant structural variable | Add centre-right / centre-left / fragmented branches per §0 | +| "Black swan" scenario in main set | Misclassification | Black swans → `wildcards-blackswans.md`; in long-horizon, 2 wildcard branches are mandatory | | Forecast without monitoring plan | Cannot be revised | §6 monitoring plan with dates and decision points | +| Long-horizon WEP band out of range | Violates decay table | T+EP-election bands must use "Highly Unlikely" to "Almost No Chance" per [`forward-projection-methodology.md §3`](../methodologies/forward-projection-methodology.md#3-wep-decay-table) | ## 🎯 EP MCP tool inputs @@ -260,12 +304,15 @@ exclusive. ## 🔗 Controlling methodology cross-references - [`../methodologies/strategic-extensions-methodology.md`](../methodologies/strategic-extensions-methodology.md) §Scenarios +- [`../methodologies/forward-projection-methodology.md §3`](../methodologies/forward-projection-methodology.md#3-wep-decay-table) — **canonical WEP decay table** (single source of truth for horizon-conditional WEP bands; do NOT duplicate numbers here) - [`../methodologies/osint-tradecraft-standards.md §3 WEP`](../methodologies/osint-tradecraft-standards.md) -- [`forward-indicators.md`](forward-indicators.md) — companion artifact for tripwire signposts -- [`wildcards-blackswans.md`](wildcards-blackswans.md) — for ≤5% probability tail risks +- [`forward-indicators.md`](forward-indicators.md) — companion artifact for tripwire signposts; see its §Multi-Horizon Decay Table for indicator horizon tags +- [`wildcards-blackswans.md`](wildcards-blackswans.md) — for ≤5% probability tail risks; long-horizon mode requires ≥2 wildcard branches ## ✅ Stage-C completeness signals +**Standard mode (`long-horizon-mode: false`):** + - Line floor: 280 lines - ≥ 3 main scenarios with WEP-band probabilities summing to 100% - Each scenario has ≥1 named driver and ≥3 monitorable indicators @@ -273,6 +320,16 @@ exclusive. - §6 monitoring plan with at least 3 dated decision points - Confidence label per scenario (🟢/🟡/🔴) with rationale +**Long-horizon mode (`long-horizon-mode: true`, with Stage-C scenario-count enforcement for `term-outlook` or `election-cycle` article types):** + +- Line floor: 360 lines (term-outlook) / 400 lines (election-cycle) +- **≥ 6 scenarios** — enforced by Stage-C validator when article type is `term-outlook` or `election-cycle` +- Mandatory EP-election outcome branch (centre-right / centre-left / fragmented coalition) per §0 +- At least 1 dedicated regime-change branch +- At least 2 wildcard/black-swan branches +- Per-scenario WEP confidence band drawn from [`forward-projection-methodology.md §3`](../methodologies/forward-projection-methodology.md#3-wep-decay-table) decay table +- Each EP-election branch carries reference class from `historical-baseline.md §Electoral baselines` + --- -**Document Control:** `/analysis/daily/{date}/{type}-run{N}/intelligence/scenario-forecast.md` · Template v1.2 · Depth floor: 280 lines. +**Document Control:** `/analysis/daily/{date}/{type}-run{N}/intelligence/scenario-forecast.md` · Template v1.3 · Standard depth floor: 280 lines · Long-horizon depth floor: 360–400 lines. diff --git a/scripts/validate-analysis-completeness.js b/scripts/validate-analysis-completeness.js index 8d0228f4329..0a822efdbd0 100644 --- a/scripts/validate-analysis-completeness.js +++ b/scripts/validate-analysis-completeness.js @@ -72,7 +72,7 @@ const PLACEHOLDER_PATTERNS = [ ]; const WEP_BAND_RE = - /\b(Almost Certain|Highly Likely|Likely|Roughly Even|Even Chance|Unlikely|Highly Unlikely|Almost No Chance|WEP\s*:)\b/i; + /\b(Almost Certain|Highly Likely|Likely|Roughly Even|Even Chance|About even|Unlikely|Highly Unlikely|Almost No Chance|WEP\s*:)\b/i; const ADMIRALTY_RE = /(^|[\s|`(])([A-F][1-6])([\s|`)]|$)/; @@ -647,24 +647,102 @@ function mergeUnique(left, right) { return [...new Set([...(left || []), ...(right || [])])]; } -function mergeForwardRegistryResult(results, forwardRegistryResult) { - if (!forwardRegistryResult) return; +function mergeSyntheticResult(results, syntheticResult) { + if (!syntheticResult) return; const existingResultIndex = results.findIndex( - (result) => result.relativePath === forwardRegistryResult.relativePath, + (result) => result.relativePath === syntheticResult.relativePath, ); if (existingResultIndex >= 0) { const existingResult = results[existingResultIndex]; results[existingResultIndex] = { ...existingResult, - issues: mergeUnique(existingResult.issues, forwardRegistryResult.issues), - warnings: mergeUnique(existingResult.warnings, forwardRegistryResult.warnings), + issues: mergeUnique(existingResult.issues, syntheticResult.issues), + warnings: mergeUnique(existingResult.warnings, syntheticResult.warnings), }; return; } - results.push(forwardRegistryResult); + results.push(syntheticResult); +} + +/** + * Return the portion of a scenario-forecast artifact that should contribute + * to the scenario-count gate. + * + * Worked examples in the template may contain `### Scenario ...` headings + * that must not satisfy the minimum authored-scenarios requirement, so + * everything from the first `## ... Worked example` H2 onwards is excluded. + */ +function getScenarioCountableContent(content) { + const workedExampleHeader = /^##\s+.*Worked example\b.*$/im; + const match = workedExampleHeader.exec(content); + if (!match || typeof match.index !== 'number') { + return content; + } + return content.slice(0, match.index); +} + +/** + * Count the number of scenario headings in a scenario-forecast artifact. + * Matches authored `### Scenario N:` and `### Scenario N —` patterns and + * also supports hyphen-separated alphanumeric identifiers such as `A-24`. + * Underscore-containing identifiers are still excluded to avoid false + * positives. Headings in the worked-example section are ignored. + */ +function countScenarios(content) { + const countableContent = getScenarioCountableContent(content); + + // Match "### Scenario 1:", "### Scenario A —", or "### Scenario A-24 —" + // while rejecting underscore-containing identifiers. + const re = /^###\s+Scenario\s+[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*\s*[:—]/gm; + const matches = countableContent.match(re); + return matches ? matches.length : 0; +} + +/** + * Validate the long-horizon scenario-count gate. + * + * When the article type is in `longHorizonScenarioGate.articleTypes`, + * `intelligence/scenario-forecast.md` MUST contain at least + * `longHorizonScenarioGate.minScenarios` scenario headings. + * Returns a synthetic result object if the gate fires, or null if it passes. + */ +function validateLongHorizonScenarioGate(runDir, rules) { + if (!rules.longHorizonScenarioGate) return null; + const { artifact, minScenarios } = rules.longHorizonScenarioGate; + + // Reject absolute paths or path-traversal segments to prevent reading + // arbitrary files outside runDir. + if (path.isAbsolute(artifact) || artifact.includes('..')) { + throw new Error( + `long-horizon-scenario-gate:invalid-config artifact="${artifact}" ` + + 'must be a relative path without ".." segments.' + ); + } + const absPath = path.resolve(runDir, artifact); + if (!absPath.startsWith(path.resolve(runDir) + path.sep)) { + throw new Error( + `long-horizon-scenario-gate:invalid-config artifact="${artifact}" ` + + 'resolves outside runDir.' + ); + } + if (!fs.existsSync(absPath)) return null; // already caught as missing artifact + const content = fs.readFileSync(absPath, 'utf8'); + const count = countScenarios(content); + if (count >= minScenarios) return null; // gate passes + + return { + relativePath: artifact, + exists: true, + lines: countLines(content), + minLines: 0, + issues: [`long-horizon-scenario-count:${count}<${minScenarios}`], + warnings: [], + mermaid: hasMermaid(content), + placeholders: [], + }; } function buildRules(thresholdsJson, articleType) { @@ -678,6 +756,7 @@ function buildRules(thresholdsJson, articleType) { satDocumentationRequired: [], readerBlockRequired: [], sourceDiversityRequired: [], + longHorizonScenarioGate: null, }; if (!thresholdsJson) return empty; @@ -685,6 +764,36 @@ function buildRules(thresholdsJson, articleType) { const tradecraft = thresholdsJson.tradecraftQualitySignals || {}; const structural = thresholdsJson.structuralRequirements || {}; + // Load long-horizon scenario gate config from JSON if present. + const lhGateCfg = structural.longHorizonScenarioGate || null; + let longHorizonScenarioGate = null; + if ( + lhGateCfg && + Array.isArray(lhGateCfg.articleTypes) && + lhGateCfg.articleTypes.includes(articleType) + ) { + // Both artifact and minScenarios are required fields — do not silently + // default them; a malformed config must fail Stage C instead of bypassing + // the gate for targeted article types. + const hasValidArtifact = + typeof lhGateCfg.artifact === 'string' && lhGateCfg.artifact.trim().length > 0; + const hasValidMinScenarios = + typeof lhGateCfg.minScenarios === 'number' && lhGateCfg.minScenarios > 0; + + if (!hasValidArtifact || !hasValidMinScenarios) { + throw new Error( + `long-horizon-scenario-gate:invalid-config articleType=${articleType} ` + + 'structuralRequirements.longHorizonScenarioGate must define a non-empty ' + + '`artifact` and a positive numeric `minScenarios` for targeted article types.', + ); + } + + longHorizonScenarioGate = { + artifact: lhGateCfg.artifact.trim(), + minScenarios: lhGateCfg.minScenarios, + }; + } + return { perArtifactFloors, mermaidRequired: structural.mermaidRequired || [], @@ -695,6 +804,7 @@ function buildRules(thresholdsJson, articleType) { satDocumentationRequired: tradecraft.satDocumentationRequired || [], readerBlockRequired: structural.readerBlockRequired || [], sourceDiversityRequired: structural.sourceDiversityRequired || [], + longHorizonScenarioGate, }; } @@ -789,7 +899,16 @@ function main() { } const thresholdsJson = loadThresholds(opts.thresholdsPath); - const rules = buildRules(thresholdsJson, articleType); + let rules; + try { + rules = buildRules(thresholdsJson, articleType); + } catch (err) { + process.stderr.write(`error: ${err.message}\n`); + process.stdout.write( + `STAGE_C_GATE: RED articleType=${articleType} missing=0 short=0 placeholders=0\n`, + ); + process.exit(1); + } const manifestArtifacts = flattenManifestArtifacts(manifest); const onDisk = walkArtifacts(runDir); @@ -801,7 +920,32 @@ function main() { ); const forwardRegistryResult = validateForwardStatementsRegistryCoverage(runDir, articleType); - mergeForwardRegistryResult(results, forwardRegistryResult); + mergeSyntheticResult(results, forwardRegistryResult); + + // ── Long-horizon scenario-count gate ───────────────────────────────────── + // For term-outlook and election-cycle article types, scenario-forecast.md + // MUST contain >= 6 scenario headings. See analysis/templates/scenario-forecast.md + // §0 and analysis/methodologies/reference-quality-thresholds.json + // structuralRequirements.longHorizonScenarioGate. + try { + const longHorizonScenarioResult = validateLongHorizonScenarioGate(runDir, rules); + if (longHorizonScenarioResult) { + mergeSyntheticResult(results, longHorizonScenarioResult); + } + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + mergeSyntheticResult(results, { + relativePath: 'intelligence/scenario-forecast.md', + exists: false, + lines: 0, + minLines: 0, + issues: [`long-horizon-scenario-gate:error — ${message}`], + warnings: [], + mermaid: false, + placeholders: [], + }); + } // ── Re-run improve/extend enforcement ──────────────────────────────────── // Detect whether this is a re-run of an existing same-day analysis by diff --git a/test/unit/validate-analysis-completeness.test.js b/test/unit/validate-analysis-completeness.test.js index 560215e6ccf..015ddaa1eb8 100644 --- a/test/unit/validate-analysis-completeness.test.js +++ b/test/unit/validate-analysis-completeness.test.js @@ -1067,4 +1067,330 @@ describe('scripts/validate-analysis-completeness.js', () => { expect(result.code).toBe(0); expect(result.stdout).toMatch(/STAGE_C_GATE: GREEN/); }); + + describe('Long-horizon scenario-count gate', () => { + function writeLongHorizonThresholds(articleType) { + fs.writeFileSync( + thresholdsPath, + JSON.stringify({ + thresholds: { + [articleType]: { + 'intelligence/scenario-forecast.md': 360, + }, + }, + tradecraftQualitySignals: {}, + structuralRequirements: { + longHorizonScenarioGate: { + articleTypes: ['term-outlook', 'election-cycle'], + minScenarios: 6, + artifact: 'intelligence/scenario-forecast.md', + }, + }, + }), + 'utf8', + ); + } + + function writeLongHorizonManifest(articleType) { + fs.writeFileSync( + path.join(runDir, 'manifest.json'), + JSON.stringify({ + articleType, + files: { + intelligence: ['intelligence/scenario-forecast.md'], + }, + }), + 'utf8', + ); + } + + function makeScenarioForecast(scenarioCount) { + const lines = [ + '# Scenario Forecast', + '', + '## 1️⃣ Horizon Statement', + '', + 'Horizon: 36 months.', + '', + '```mermaid', + 'flowchart TD', + ' BASELINE[Baseline] --> S1[Scenario 1]', + '```', + '', + ]; + for (let i = 1; i <= scenarioCount; i += 1) { + lines.push(`### Scenario ${i}: Some Scenario`); + lines.push(''); + lines.push('Narrative text here.'); + lines.push(''); + } + // Pad to 400 lines for the floor + while (lines.length < 400) lines.push(`Filler line ${lines.length}`); + return lines.join('\n'); + } + + it('returns RED for term-outlook when scenario-forecast has fewer than 6 scenarios', () => { + writeLongHorizonThresholds('term-outlook'); + writeLongHorizonManifest('term-outlook'); + fs.writeFileSync( + path.join(runDir, 'intelligence/scenario-forecast.md'), + makeScenarioForecast(4), + 'utf8', + ); + const result = runHere(); + expect(result.code).toBe(1); + expect(result.stderr).toMatch(/long-horizon-scenario-count:4<6/); + expect(result.stdout).toMatch(/STAGE_C_GATE: RED/); + }); + + it('passes GREEN for term-outlook when scenario-forecast has exactly 6 scenarios', () => { + writeLongHorizonThresholds('term-outlook'); + writeLongHorizonManifest('term-outlook'); + fs.writeFileSync( + path.join(runDir, 'intelligence/scenario-forecast.md'), + makeScenarioForecast(6), + 'utf8', + ); + const result = runHere(); + expect(result.code).toBe(0); + expect(result.stdout).toMatch(/STAGE_C_GATE: GREEN/); + }); + + it('passes GREEN for term-outlook when scenario-forecast has more than 6 scenarios', () => { + writeLongHorizonThresholds('term-outlook'); + writeLongHorizonManifest('term-outlook'); + fs.writeFileSync( + path.join(runDir, 'intelligence/scenario-forecast.md'), + makeScenarioForecast(8), + 'utf8', + ); + const result = runHere(); + expect(result.code).toBe(0); + expect(result.stdout).toMatch(/STAGE_C_GATE: GREEN/); + }); + + it('returns RED for election-cycle when scenario-forecast has fewer than 6 scenarios', () => { + writeLongHorizonThresholds('election-cycle'); + writeLongHorizonManifest('election-cycle'); + fs.writeFileSync( + path.join(runDir, 'intelligence/scenario-forecast.md'), + makeScenarioForecast(3), + 'utf8', + ); + const result = runHere(); + expect(result.code).toBe(1); + expect(result.stderr).toMatch(/long-horizon-scenario-count:3<6/); + }); + + it('does NOT apply scenario-count gate to breaking article type', () => { + // Use the long-horizon thresholds but breaking article type — + // the gate should not fire even if scenario-forecast has only 3 scenarios. + writeLongHorizonThresholds('breaking'); + fs.writeFileSync( + path.join(runDir, 'manifest.json'), + JSON.stringify({ + articleType: 'breaking', + files: { + intelligence: ['intelligence/scenario-forecast.md'], + }, + }), + 'utf8', + ); + fs.writeFileSync( + path.join(runDir, 'intelligence/scenario-forecast.md'), + makeScenarioForecast(3), + 'utf8', + ); + const result = runHere(); + // Gate is not in the breaking thresholds, so no long-horizon issue. + expect(result.stderr).not.toMatch(/long-horizon-scenario-count/); + }); + + it('does NOT apply scenario-count gate when longHorizonScenarioGate is absent from thresholds', () => { + // Use minimal thresholds with no longHorizonScenarioGate field + fs.writeFileSync( + thresholdsPath, + JSON.stringify({ + thresholds: { + 'term-outlook': { + 'intelligence/scenario-forecast.md': 360, + }, + }, + tradecraftQualitySignals: {}, + structuralRequirements: {}, + }), + 'utf8', + ); + writeLongHorizonManifest('term-outlook'); + fs.writeFileSync( + path.join(runDir, 'intelligence/scenario-forecast.md'), + makeScenarioForecast(3), + 'utf8', + ); + const result = runHere(); + expect(result.stderr).not.toMatch(/long-horizon-scenario-count/); + }); + + it('exits 1 with invalid-config error when longHorizonScenarioGate targets articleType but has missing artifact', () => { + fs.writeFileSync( + thresholdsPath, + JSON.stringify({ + thresholds: {}, + tradecraftQualitySignals: {}, + structuralRequirements: { + longHorizonScenarioGate: { + articleTypes: ['term-outlook'], + minScenarios: 6, + // artifact field intentionally omitted + }, + }, + }), + 'utf8', + ); + writeLongHorizonManifest('term-outlook'); + fs.writeFileSync( + path.join(runDir, 'intelligence/scenario-forecast.md'), + makeScenarioForecast(6), + 'utf8', + ); + const result = runHere(); + expect(result.code).toBe(1); + expect(result.stderr).toMatch(/long-horizon-scenario-gate:invalid-config/); + }); + + it('exits 1 with invalid-config error when longHorizonScenarioGate targets articleType but minScenarios is zero', () => { + fs.writeFileSync( + thresholdsPath, + JSON.stringify({ + thresholds: {}, + tradecraftQualitySignals: {}, + structuralRequirements: { + longHorizonScenarioGate: { + articleTypes: ['term-outlook'], + minScenarios: 0, + artifact: 'intelligence/scenario-forecast.md', + }, + }, + }), + 'utf8', + ); + writeLongHorizonManifest('term-outlook'); + fs.writeFileSync( + path.join(runDir, 'intelligence/scenario-forecast.md'), + makeScenarioForecast(6), + 'utf8', + ); + const result = runHere(); + expect(result.code).toBe(1); + expect(result.stderr).toMatch(/long-horizon-scenario-gate:invalid-config/); + }); + + it('counts hyphenated scenario IDs (e.g. A-24) correctly', () => { + writeLongHorizonThresholds('term-outlook'); + writeLongHorizonManifest('term-outlook'); + // Build a scenario-forecast using hyphenated IDs + const lines = [ + '# Scenario Forecast', + '', + '## 1️⃣ Horizon Statement', + '', + 'Horizon: 36 months.', + '', + '```mermaid', + 'flowchart TD', + ' BASELINE[Baseline] --> S1[Scenario A-1]', + '```', + '', + ]; + const ids = ['A-1', 'A-2', 'A-3', 'B-1', 'B-2', 'C-1']; + for (const id of ids) { + lines.push(`### Scenario ${id}: Some Scenario`); + lines.push(''); + lines.push('Narrative text here.'); + lines.push(''); + } + while (lines.length < 400) lines.push(`Filler line ${lines.length}`); + fs.writeFileSync( + path.join(runDir, 'intelligence/scenario-forecast.md'), + lines.join('\n'), + 'utf8', + ); + const result = runHere(); + expect(result.code).toBe(0); + expect(result.stdout).toMatch(/STAGE_C_GATE: GREEN/); + }); + + it('excludes scenario headings from worked-example section', () => { + writeLongHorizonThresholds('term-outlook'); + writeLongHorizonManifest('term-outlook'); + // Only 3 real scenarios, plus 4 in the worked example — should fail + const lines = [ + '# Scenario Forecast', + '', + '## 1️⃣ Horizon Statement', + '', + 'Horizon: 36 months.', + '', + '```mermaid', + 'flowchart TD', + ' BASELINE[Baseline] --> S1[Scenario 1]', + '```', + '', + ]; + for (let i = 1; i <= 3; i += 1) { + lines.push(`### Scenario ${i}: Real Scenario`); + lines.push(''); + lines.push('Narrative text here.'); + lines.push(''); + } + lines.push('## 🛠️ Worked example'); + lines.push(''); + for (let i = 1; i <= 4; i += 1) { + lines.push(`### Scenario X${i}: Example Scenario`); + lines.push(''); + lines.push('Example text.'); + lines.push(''); + } + while (lines.length < 400) lines.push(`Filler line ${lines.length}`); + fs.writeFileSync( + path.join(runDir, 'intelligence/scenario-forecast.md'), + lines.join('\n'), + 'utf8', + ); + const result = runHere(); + expect(result.code).toBe(1); + expect(result.stderr).toMatch(/long-horizon-scenario-count:3<6/); + }); + + it('rejects path-traversal in artifact config', () => { + fs.writeFileSync( + thresholdsPath, + JSON.stringify({ + thresholds: { + 'term-outlook': { + 'intelligence/scenario-forecast.md': 360, + }, + }, + tradecraftQualitySignals: {}, + structuralRequirements: { + longHorizonScenarioGate: { + articleTypes: ['term-outlook'], + minScenarios: 6, + artifact: '../../../etc/passwd', + }, + }, + }), + 'utf8', + ); + writeLongHorizonManifest('term-outlook'); + fs.writeFileSync( + path.join(runDir, 'intelligence/scenario-forecast.md'), + makeScenarioForecast(6), + 'utf8', + ); + const result = runHere(); + expect(result.code).toBe(1); + expect(result.stdout).toContain('STAGE_C_GATE: RED'); + }); + }); });