From abdcdb581fcb0398eda9fdf5bd2a1680be38baf9 Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Sat, 25 Apr 2026 12:02:48 +0400 Subject: [PATCH] feat(resilience): SWF manifest expansion + KIA split + new schema fields (#3391) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(resilience): SWF manifest expansion + KIA split + new schema fields Phase 1 of plan 2026-04-25-001 (Codex-approved round 5). Manifest-only data correction; no construct change, no cache prefix bump. Schema additions (loader-validated, misplacement-rejected): - top-level: aum_usd, aum_year, aum_verified (primary-source AUM) - under classification: aum_pct_of_audited (fraction multiplier), excluded_overlaps_with_reserves (boolean; documentation-only) Manifest expansion (13 → 21 funds, 6 → 13 countries): - UAE: +ICD ($320B verified), +ADQ ($199B verified), +EIA (unverified — loaded for documentation, excluded from scoring per data-integrity rule) - KW: kia split into kia-grf (5%, access=0.9) + kia-fgf (95%, access=0.20). Corrects ~18× over-statement of crisis-deployable Kuwait sovereign wealth (audit found combined-AUM × 0.7 access applied $750B as "deployable" against ~$15B actual GRF stabilization capacity). - CN: +CIC ($1.35T), +NSSF ($400B, statutorily-gated 0.20 tier), +SAFE-IC ($417B, excluded — overlaps SAFE FX reserves) - HK: +HKMA-EF ($498B, excluded — overlaps HKMA reserves) - KR: +KIC ($182B, IFSWF full member) - AU: +Future Fund ($192B, pension-locked) - OM: +OIA ($50B, IFSWF member) - BH: +Mumtalakat ($19B) - TL: +Petroleum Fund ($22B, GPFG-style high-transparency) Re-audits (Phase 1E): - ADIA access 0.3 → 0.4 (rubric flagged; ruler-discretionary deployment empirically demonstrated) - Mubadala access 0.4 → 0.5 (rubric flagged); transparency 0.6 → 0.7 (LM=10 + IFSWF full member alignment) Rubric (docs/methodology/swf-classification-rubric.md): - New "Statutorily-gated long-horizon" 0.20 access tier added between 0.1 (sanctions/frozen) and 0.3 (intergenerational/ruler-discretionary). Anchored by KIA-FGF (Decree 106 of 1976; Council-of-Ministers + Emir decree gate; crossed once in extremis during COVID). Seeder: - Two new pure helpers: shouldSkipFundForBuffer (excluded/unverified decision) and applyAumPctOfAudited (sleeve fraction multiplier) - Manifest-AUM bypass: if aum_verified=true AND aum_usd present, use that value directly (skip Wikipedia) - Skip funds with excluded_overlaps_with_reserves=true (no double-counting against reserveAdequacy / liquidReserveAdequacy) - Skip funds with aum_verified=false (load for documentation only) Tests (+25 net): - 15 schema-extension tests (misplacement rejection, value-range gates, rationale-pairing coherence, backward-compat with pre-PR entries) - 10 helper tests (shouldSkipFundForBuffer + applyAumPctOfAudited predicates and arithmetic; KIA-GRF + KIA-FGF sum equals combined AUM) - Existing manifest test updated for the kia → kia-grf+kia-fgf split Full suite: 6,940 tests pass (+50 net), typecheck clean, no new lint. Predicted ranking deltas (informational, NOT acceptance criteria per plan §"Hard non-goals"): - AE sovFiscBuf likely 39 → 47-49 (Phase 1A + 1E) - KW sovFiscBuf likely 98 → 53-57 (Phase 1B) - CN, HK (excluded), KR, AU acquire newly-defined sovFiscBuf scores - GCC ordering shifts toward QA > KW > AE; AE-KW gap likely 6 → ~3-4 Real outcome will be measured post-deploy via cohort audit per plan §Phase 4. * fix(resilience): completeness denominator excludes documentation-only funds PR-3391 review (P1 catch): the per-country `expectedFunds` denominator counted ALL manifest entries (`funds.length`) including those skipped from buffer scoring by design — `excluded_overlaps_with_reserves: true` (SAFE-IC, HKMA-EF) and `aum_verified: false` (EIA). Result: countries with mixed scorable + non-scorable rosters showed `completeness < 1.0` even when every scorable fund matched. UAE (4 scorable + EIA) would show 0.8; CN (CIC + NSSF + SAFE-IC excluded) would show 0.67. The downstream scorer then derated those countries' coverage based on a fake-partial signal. Three call sites all carried the same bug: - per-country `expectedFunds` in fetchSovereignWealth main loop - `expectedFundsTotal` + `expectedCountries` in buildCoverageSummary - `countManifestFundsForCountry` (missing-country path) All three now filter via `shouldSkipFundForBuffer` to count only scorable manifest entries. Documentation-only funds neither expected nor matched — they don't appear in the ratio at all. Tests added (+4): - AE complete with all 4 scorable matched (EIA documented but excluded) - CN complete with CIC + NSSF matched (SAFE-IC documented but excluded) - Missing-country path returns scorable count not raw manifest count - Country with ONLY documentation-only entries excluded from expectedCountries Full suite: 6,944 tests pass (+4 net), typecheck clean. * fix(resilience): address Greptile P2s on PR #3391 manifest Three review findings, all in the manifest YAML: 1. **KIA-GRF access 0.9 → 0.7** (rubric alignment): GRF deployment requires active Council-of-Ministers authorization (2020 COVID precedent demonstrates this), not rule-triggered automatic deployment. The rubric's 0.9 tier ("Pure automatic stabilization") reserved for funds where political authorization is post-hoc / symbolic (Chile ESSF candidate). KIA-GRF correctly fits 0.7 ("Explicit stabilization with rule") — the same tier the pre-split combined-KIA was assigned. Updated rationale clarifies the tier choice. Rubric's 0.7 precedent column already lists "KIA General Reserve Fund" — now consistent with the manifest. 2. **Duplicate `# ── Australia ──` header before Oman** (copy-paste artifact): removed the orphaned header at the Oman section; added proper `# ── Australia ──` header above the Future Fund entry where it actually belongs (after Timor-Leste). 3. **NSSF `aum_pct_of_audited: 1.0` removed** (no-op): a multiplier of 1.0 is identity. The schema field is OPTIONAL and only meant for fund-of-funds split entries (e.g. KIA-GRF/FGF). Setting it to 1.0 forced the loader to require an `aum_pct_of_audited` rationale paragraph with no computational benefit. Both the field and the paragraph are now removed; NSSF remains a single- sleeve entry that scores its full audited AUM. Full suite: 6,944 tests pass, typecheck clean. --- docs/methodology/swf-classification-rubric.md | 1 + scripts/seed-sovereign-wealth.mjs | 119 +++- .../shared/swf-classification-manifest.yaml | 530 +++++++++++++++++- scripts/shared/swf-manifest-loader.mjs | 131 ++++- tests/swf-classification-manifest.test.mjs | 34 +- ...-manifest-loader-schema-extension.test.mjs | 387 +++++++++++++ 6 files changed, 1171 insertions(+), 31 deletions(-) create mode 100644 tests/swf-manifest-loader-schema-extension.test.mjs diff --git a/docs/methodology/swf-classification-rubric.md b/docs/methodology/swf-classification-rubric.md index cdbcfd38e..19c10f355 100644 --- a/docs/methodology/swf-classification-rubric.md +++ b/docs/methodology/swf-classification-rubric.md @@ -53,6 +53,7 @@ Deployment SPEED (weeks vs months vs years) is the core signal. | Tier | Value | Meaning | Concrete precedents | |---|---|---|---| | Nil access | **0.1** | Sanctions, asset freeze, or political paralysis makes deployment effectively impossible within a crisis window | Russia NWF (post-2022 asset freeze), Libya LIA (sanctions + frozen assets), Iran NDFI (sanctions + access concerns). Currently deferred from v1 for this reason. | +| Statutorily-gated long-horizon | **0.20** | Withdrawals require statutory supermajority / bicameral-equivalent action; gate has been crossed in extreme cases (single, capped draw under emergency law) but NOT for ordinary stabilization. Distinct from "Intergenerational savings" (0.3) because the gate is *statutory* rather than ruler-discretionary — Council-of-Ministers + parliamentary or constitutional thresholds replace head-of-state direction. | KIA Future Generations Fund (Decree 106 of 1976; Council-of-Ministers + Emir decree required; gate crossed once during COVID for a small capped draw). Phase 1B addition (Plan 2026-04-25-001). | | Intergenerational savings | **0.3** | Pure long-horizon wealth-preservation mandate; no explicit stabilization rule; withdrawal requires ruler / head-of-state / parliamentary discretion with no codified trigger | ADIA (Abu Dhabi, intergenerational mandate, ruler-discretionary); Brunei BIA (deferred candidate) | | Hybrid / constrained | **0.5** | Mandate mixes strategic + savings + partial stabilization; deployment is mechanically possible but constrained by strategic allocation locked to policy objectives (Vision 2030, industrial policy, geopolitical holdings) | PIF (Saudi Arabia, Vision 2030-locked), QIA (Qatar, long-horizon wealth-management with amiri-decree deployment), Mubadala (UAE, strategic + financial hybrid), Ireland ISIF (strategic-development mandate) | | Explicit stabilization with rule | **0.7** | Legislated or rule-based mechanism for fiscal support during specific shock classes, with historical precedent of actual deployment | KIA General Reserve Fund (legislated finance of budget shortfalls from oil-revenue swings). NO GPFG is BORDERLINE — has a fiscal rule capping withdrawal at ~3% expected real return, which is an access MECHANISM but also an access CONSTRAINT (see below). NOTE: GIC is discussed in the alignment table below as a candidate for this tier based on its NIRC framework, but the current manifest rates it 0.6 — so it's a 0.7 *candidate*, not a 0.7 *precedent*. | diff --git a/scripts/seed-sovereign-wealth.mjs b/scripts/seed-sovereign-wealth.mjs index 1042d363f..2535338ec 100644 --- a/scripts/seed-sovereign-wealth.mjs +++ b/scripts/seed-sovereign-wealth.mjs @@ -655,6 +655,56 @@ async function fetchWikipediaInfobox(fund, fxRates) { // ── Aggregation ── +/** + * Pure predicate: should this manifest fund be SKIPPED from the + * SWF buffer calculation? Returns the skip reason string or null. + * + * Two skip conditions (Phase 1 §schema): + * - `excluded_overlaps_with_reserves: true` — AUM already counted + * in central-bank FX reserves (SAFE-IC, HKMA-EF). Excluding + * prevents double-counting against reserveAdequacy / + * liquidReserveAdequacy. + * - `aum_verified: false` — fund AUM not primary-source-confirmed. + * Loaded for documentation; excluded from scoring per the + * data-integrity rule (Codex Round 1 #7). + * + * Pure function — exported for tests. + * + * @param {{ classification?: { excludedOverlapsWithReserves?: boolean }, aumVerified?: boolean }} fund + * @returns {'excluded_overlaps_with_reserves' | 'aum_unverified' | null} + */ +export function shouldSkipFundForBuffer(fund) { + if (fund?.classification?.excludedOverlapsWithReserves === true) { + return 'excluded_overlaps_with_reserves'; + } + if (fund?.aumVerified === false) { + return 'aum_unverified'; + } + return null; +} + +/** + * Pure helper: apply the `aum_pct_of_audited` multiplier to a + * resolved AUM value. When the fund's classification has no + * `aum_pct_of_audited`, returns the AUM unchanged. + * + * Used for fund-of-funds split entries (e.g. KIA-GRF is ~5% of the + * audited KIA total; KIA-FGF is ~95%). + * + * Pure function — exported for tests. + * + * @param {number} resolvedAumUsd + * @param {{ classification?: { aumPctOfAudited?: number } }} fund + * @returns {number} + */ +export function applyAumPctOfAudited(resolvedAumUsd, fund) { + const pct = fund?.classification?.aumPctOfAudited; + if (typeof pct === 'number' && pct > 0 && pct <= 1) { + return resolvedAumUsd * pct; + } + return resolvedAumUsd; +} + async function fetchFundAum(fund, wikipediaCache, fxRates) { // Source priority: official → IFSWF → Wikipedia list → Wikipedia // per-fund infobox. Short-circuit on first non-null return so the @@ -779,22 +829,41 @@ export async function fetchSovereignWealth() { const fundRecords = []; for (const fund of funds) { - const aum = await fetchFundAum(fund, wikipediaCache, fxRates); + const skipReason = shouldSkipFundForBuffer(fund); + if (skipReason) { + console.log(`[seed-sovereign-wealth] ${fund.country}:${fund.fund} skipped — ${skipReason}`); + continue; + } + + // AUM resolution: prefer manifest-provided primary-source AUM + // when verified; fall back to the existing Wikipedia/IFSWF + // resolution chain otherwise (existing entries that pre-date + // the schema extension still work unchanged). + let aum = null; + if (fund.aumVerified === true && typeof fund.aumUsd === 'number') { + aum = { aum: fund.aumUsd, aumYear: fund.aumYear ?? null, source: 'manifest_primary' }; + } else { + aum = await fetchFundAum(fund, wikipediaCache, fxRates); + } if (!aum) { unmatched.push(`${fund.country}:${fund.fund}`); continue; } + + const adjustedAum = applyAumPctOfAudited(aum.aum, fund); + const aumPct = fund.classification?.aumPctOfAudited; sourceMix[aum.source] = (sourceMix[aum.source] ?? 0) + 1; const { access, liquidity, transparency } = fund.classification; - const rawMonths = (aum.aum / denominatorImports) * 12; + const rawMonths = (adjustedAum / denominatorImports) * 12; const effectiveMonths = rawMonths * access * liquidity * transparency; fundRecords.push({ fund: fund.fund, - aum: aum.aum, + aum: adjustedAum, aumYear: aum.aumYear, source: aum.source, + ...(aumPct != null ? { aumPctOfAudited: aumPct } : {}), access, liquidity, transparency, @@ -805,9 +874,23 @@ export async function fetchSovereignWealth() { if (fundRecords.length === 0) continue; const totalEffectiveMonths = fundRecords.reduce((s, f) => s + f.effectiveMonths, 0); - const expectedFunds = funds.length; + // Completeness denominator excludes funds that were INTENTIONALLY + // skipped from buffer scoring (excluded_overlaps_with_reserves OR + // aum_verified=false). Without this, manifest entries that exist + // for documentation only would artificially depress completeness + // for countries with mixed scorable + non-scorable funds — e.g. + // UAE (4 scorable + EIA unverified) would show completeness=0.8 + // even when every scorable fund matched, and CN (CIC + NSSF + // scorable + SAFE-IC excluded) would show 0.67. + // + // The right denominator is "scorable funds for this country": + // funds where shouldSkipFundForBuffer returns null. Documentation- + // only entries are neither matched nor expected; they don't appear + // in the ratio at all. + const scorableFunds = funds.filter((f) => shouldSkipFundForBuffer(f) === null); + const expectedFunds = scorableFunds.length; const matchedFunds = fundRecords.length; - const completeness = matchedFunds / expectedFunds; + const completeness = expectedFunds > 0 ? matchedFunds / expectedFunds : 0; // `completeness` signals partial-seed on multi-fund countries (AE, // SG). Downstream scorer must derate the country when completeness // < 1.0 — silently emitting partial totalEffectiveMonths would @@ -816,7 +899,7 @@ export async function fetchSovereignWealth() { // use the partial number for IMPUTE-level coverage), but only // completeness=1.0 countries count toward recordCount / health. if (completeness < 1.0) { - console.warn(`[seed-sovereign-wealth] ${iso2} partial: ${matchedFunds}/${expectedFunds} funds matched — completeness=${completeness.toFixed(2)}`); + console.warn(`[seed-sovereign-wealth] ${iso2} partial: ${matchedFunds}/${expectedFunds} scorable funds matched — completeness=${completeness.toFixed(2)}`); } countries[iso2] = { funds: fundRecords, @@ -886,8 +969,16 @@ export async function fetchSovereignWealth() { * @param {Record} countries Seeded country payload */ export function buildCoverageSummary(manifest, imports, countries) { - const expectedFundsTotal = manifest.funds.length; - const expectedCountries = new Set(manifest.funds.map((f) => f.country)); + // Coverage denominator excludes manifest entries that are + // documentation-only by design — funds with + // `excluded_overlaps_with_reserves: true` (SAFE-IC, HKMA-EF) or + // `aum_verified: false` (EIA). Counting them as "expected" would + // depress the headline coverage ratio for countries with mixed + // scorable + non-scorable fund rosters. Same fix as the per-country + // completeness denominator above; see comment there. + const scorableManifestFunds = manifest.funds.filter((f) => shouldSkipFundForBuffer(f) === null); + const expectedFundsTotal = scorableManifestFunds.length; + const expectedCountries = new Set(scorableManifestFunds.map((f) => f.country)); let matchedFundsTotal = 0; for (const entry of Object.values(countries)) matchedFundsTotal += entry.matchedFunds; // Every status carries a `reason` field so downstream consumers that @@ -925,8 +1016,18 @@ export function buildCoverageSummary(manifest, imports, countries) { } function countManifestFundsForCountry(manifest, iso2) { + // Counts SCORABLE funds for the given country (excludes documentation- + // only entries: `excluded_overlaps_with_reserves: true` and + // `aum_verified: false`). Used by buildCoverageSummary's missing- + // country path so the "expected" figure on a missing country reflects + // what the seeder would actually try to score, not all manifest + // entries. let n = 0; - for (const f of manifest.funds) if (f.country === iso2) n++; + for (const f of manifest.funds) { + if (f.country !== iso2) continue; + if (shouldSkipFundForBuffer(f) !== null) continue; + n++; + } return n; } diff --git a/scripts/shared/swf-classification-manifest.yaml b/scripts/shared/swf-classification-manifest.yaml index 17f595933..c6c7a0452 100644 --- a/scripts/shared/swf-classification-manifest.yaml +++ b/scripts/shared/swf-classification-manifest.yaml @@ -61,7 +61,7 @@ # re-runs the seeder against the new entry to confirm 8/N live match. manifest_version: 1 -last_reviewed: 2026-04-23 +last_reviewed: 2026-04-25 # REVIEWED means: coefficients derive from the committed rationale + # sources block and the seeder end-to-end matches the expected funds # against the live Wikipedia / IFSWF / official-disclosure surfaces. @@ -111,13 +111,26 @@ funds: abbrev: ADIA fund_name: Abu Dhabi Investment Authority classification: - access: 0.3 + # Phase 1E re-audit (Plan 2026-04-25-001): bumped from 0.3 → 0.4. + # ADIA's official mandate is intergenerational, but its + # ruler-discretionary deployment pattern (2009 Mubadala bailout + # precedent, recurring budget-support contributions to Abu Dhabi + # treasury) reflects higher empirical access than the 0.3 + # "intergenerational" tier suggests. The 0.4 mid-tier value sits + # between intergenerational (0.3) and hybrid-constrained (0.5). + access: 0.4 liquidity: 0.7 transparency: 0.5 rationale: access: | - Intergenerational savings mandate; no explicit stabilization - access rule. Ruler-discretionary deployment. Low-medium access. + Official mandate is long-horizon intergenerational savings, + but ruler-discretionary deployment has been demonstrated: + Mubadala 2009 bailout precedent, periodic budget-support + contributions to Abu Dhabi treasury, strategic infusions + during Dubai's 2009 GCC crisis. Empirical access falls + between the strict intergenerational tier (0.3) and the + hybrid-constrained tier (0.5). Phase 1E re-audit bumped + the score from 0.3 → 0.4 to reflect this. liquidity: | ADIA 2024 review discloses ~55-70% public-market (equities + bonds) allocation, balance in alternatives and real assets. @@ -134,24 +147,143 @@ funds: wikipedia: fund_name: Mubadala Investment Company classification: - access: 0.4 + # Phase 1E re-audit (Plan 2026-04-25-001): rubric flagged + # Mubadala's pre-PR access=0.4 as below the 0.5 hybrid-tier + # midpoint and transparency=0.6 as under-rated for an LM=10 + # IFSWF full member. Both bumped to align with the rubric. + access: 0.5 liquidity: 0.5 - transparency: 0.6 + transparency: 0.7 rationale: access: | Strategic + financial hybrid mandate — combines economic- - diversification assets with financial investments. Medium - access for fiscal support; constrained by strategic holdings. + diversification assets with financial investments. The 2024 + ADQ-related corporate restructuring (consolidation of Abu + Dhabi state investment vehicles) reinforces Mubadala's + treatment as a hybrid-constrained 0.5-tier vehicle: deployable + for fiscal support, constrained by strategic holdings. + Access bumped from 0.4 → 0.5 in Phase 1E re-audit. liquidity: | Mixed: ~50% public equities + credit, ~50% private equity, real estate, infrastructure (Mubadala 2024 annual report). transparency: | Audited AUM published, asset-mix disclosed annually. IFSWF - member. LM index = 10. + full member. LM index = 10. Bumped from 0.6 → 0.7 in + Phase 1E re-audit to align with the rubric tier (0.7 = audited + AUM + asset-class mix + returns disclosed annually). sources: - https://www.mubadala.com/en/annual-review - https://www.ifswf.org/member-profiles/mubadala-investment-company + # Investment Corporation of Dubai — Dubai-government holding + # company. Owns Emirates Airlines, ENBD, Dubai Holdings, and + # significant Emaar / DEWA stakes. Distinct from ADIA (Abu Dhabi + # emirate) and Mubadala (Abu Dhabi strategic). Phase 1A addition. + - country: AE + fund: icd + display_name: Investment Corporation of Dubai (ICD) + wikipedia: + abbrev: ICD + fund_name: Investment Corporation of Dubai + aum_usd: 320000000000 + aum_year: 2023 + aum_verified: true + classification: + access: 0.5 + liquidity: 0.5 + transparency: 0.4 + rationale: + access: | + Dubai-government holding company; finances Dubai-emirate + budget directly during shocks. 2009 GCC bailout precedent + when ICD-affiliated entities (Dubai World, Nakheel) were + actively rolled into broader fiscal support. Hybrid- + constrained 0.5 tier — deployment is mechanically possible + but constrained by strategic holdings (Emirates Airlines, + ENBD). + liquidity: | + Mixed portfolio: Emaar + ENBD + DEWA + Borse Dubai listed + equity stakes (~50% public market) balanced against + Emirates Airlines + Dubai Holdings real estate (~50% + private). 0.5 mid-liquidity tier. + transparency: | + ICD publishes consolidated audited financial highlights but + does not disclose holdings-level detail. LM index ~4. + Annual review available via icd.gov.ae but constituent + AUM disclosure is partial. + sources: + - https://www.icd.gov.ae/en/about-icd/our-portfolio/ + - https://www.icd.gov.ae/en/news-and-publications/ + + # ADQ (Abu Dhabi Developmental Holding Company) — strategic + # investment vehicle established 2018, distinct from ADIA and + # Mubadala. Phase 1A addition. + - country: AE + fund: adq + display_name: Abu Dhabi Developmental Holding Company (ADQ) + wikipedia: + abbrev: ADQ + fund_name: ADQ + aum_usd: 199000000000 + aum_year: 2024 + aum_verified: true + classification: + access: 0.5 + liquidity: 0.4 + transparency: 0.4 + rationale: + access: | + Hybrid strategic-investment vehicle under Abu Dhabi + government control. Mandate covers economic diversification + + strategic asset stewardship. Medium access for fiscal + support; access discipline closer to Mubadala than to ADIA's + long-horizon savings tier. + liquidity: | + Heavy in private equity, food + agriculture (Agthia, Modern + Bakery), real estate, healthcare, and energy. Limited public- + market exposure relative to ADIA. ~0.4 mid-low liquidity. + transparency: | + ADQ publishes AUM and asset-class summaries via corporate + press releases and select press disclosures (Reuters, FT + primary reporting). IFSWF observer-only. LM index ~4. + sources: + - https://www.adq.ae/about-us/ + - https://www.adq.ae/news-and-insights/ + + # Emirates Investment Authority (EIA) — federal-level UAE wealth + # vehicle distinct from emirate-level (ADIA, ICD, ADQ, Mubadala) + # funds. Limited public disclosure — primary-source AUM not + # verifiable as of 2026-04-25; entry loaded for documentation but + # excluded from buffer scoring per data-integrity rule + # (`aum_verified: false`). Phase 1A addition; revisit when EIA + # publishes audited consolidated statements. + - country: AE + fund: eia + display_name: Emirates Investment Authority (EIA) + aum_verified: false + classification: + access: 0.4 + liquidity: 0.5 + transparency: 0.2 + rationale: + access: | + Federal-level UAE reserves vehicle (cf. emirate-level ADIA/ + ICD). Mandate covers federal fiscal stabilization for + emirate-fiscal-shock smoothing. Access is constrained by + federal/emirate political coordination; rated mid-low. + liquidity: | + Limited public disclosure; mix presumed to mirror federal- + reserves convention (majority public-market) but not + verified. + transparency: | + Limited public disclosure. Reuters cites federal-level AUM + figures but EIA itself does not publish audited annual + statements at the level of ADIA / Mubadala / ICD. LM index + ~2. Marked `aum_verified: false` until primary disclosure + materializes. + sources: + - https://www.eia.gov.ae/en/ + # ── Saudi Arabia ── # PIF combines stabilization, strategic-diversification, and domestic # development mandates. Asset mix is heavily domestic-strategic @@ -182,29 +314,97 @@ funds: - https://www.ifswf.org/members # ── Kuwait ── - # KIA runs two legally distinct funds: General Reserve Fund (budget- - # financing) and Future Generations Fund (intergenerational). Combined - # here since audited AUM is reported at the KIA level. + # KIA's audited $1.072T AUM is split here per Kuwaiti Public Funds + # Law and Decree 106 of 1976 (FGF) into two sleeves with materially + # different access profiles. Phase 1B addition (Plan 2026-04-25-001). + # Combined-AUM modeling overstated the crisis-deployable balance by + # ~18× (2026-04-24 audit) because GRF's `access=0.7` haircut was + # applied to the full $1.072T, when ~95% of that AUM is FGF and + # constitutionally gated. The split correctly attributes GRF's + # stabilization mandate to its own ~5% sleeve and FGF's + # statutorily-gated long-horizon profile to the remaining ~95%. - country: KW - fund: kia - display_name: Kuwait Investment Authority (KIA) + fund: kia-grf + display_name: Kuwait Investment Authority — General Reserve Fund (KIA-GRF) wikipedia: + # Wikipedia/SWFI report the COMBINED audited KIA AUM; the loader + # multiplies by aum_pct_of_audited (5% for GRF, 95% for FGF) to + # recover per-sleeve effective balance. No Wikipedia abbrev + # specifically for GRF. abbrev: KIA fund_name: Kuwait Investment Authority classification: access: 0.7 liquidity: 0.8 transparency: 0.4 + aum_pct_of_audited: 0.05 rationale: access: | - General Reserve Fund explicitly finances budget shortfalls from - oil-revenue swings. Strongest stabilization access in the Gulf. + Kuwaiti Public Finance Law explicitly directs GRF to absorb + oil-revenue shortfalls and finance budget deficits. Drained + to negative balance during 2020 COVID shock; refilled by + domestic + international borrowing. The 2020 deployment + required active Council-of-Ministers authorization (NOT + post-hoc/symbolic), which keeps GRF in the rubric's 0.7 + "Explicit stabilization with rule" tier — a legislated + mechanism with deployment precedent — rather than the 0.9 + "automatic stabilization" tier (which requires + rule-triggered automatic deployment, e.g. Chile ESSF). + The original combined-KIA `access=0.7` matched this tier; + kept here for the GRF sleeve. liquidity: | Predominantly public-market (~75-85% listed equities + fixed income). Private-asset sleeve is a minority allocation. + Same portfolio profile as KIA-FGF — classification independent. transparency: | Financials reported to National Assembly but sealed from public; partial IFSWF engagement. LM index = 6. + aum_pct_of_audited: | + GRF receives oil revenues and finances the budget; its + steady-state balance is roughly 5% of KIA's combined audited + AUM. The fraction varies year-to-year (negative in 2020-21 + during COVID, refilled by 2022-23). Using a representative + steady-state share avoids dependency on year-specific + balances that aren't separately disclosed. + sources: + - https://www.kia.gov.kw/en/ + - https://www.ifswf.org/member-profiles/kuwait-investment-authority + + - country: KW + fund: kia-fgf + display_name: Kuwait Investment Authority — Future Generations Fund (KIA-FGF) + wikipedia: + abbrev: KIA + fund_name: Kuwait Investment Authority + classification: + access: 0.20 + liquidity: 0.8 + transparency: 0.4 + aum_pct_of_audited: 0.95 + rationale: + access: | + FGF withdrawals require Council-of-Ministers + Emir decree + (Decree 106 of 1976; 2020 amendment did NOT remove the gate, + only added an emergency-pathway provision used once during + COVID for a small, capped draw). Not legally accessible for + ordinary stabilization. Score 0.20 reflects: (a) below the + 0.3 "intergenerational/ruler-discretionary" tier because + the gate is bicameral-equivalent + statutory, (b) above + the 0.1 "sanctions/frozen" tier because the gate has been + crossed in extremis. Anchors a NEW rubric tier between 0.1 + and 0.3 (see swf-classification-rubric.md "Statutorily-gated + long-horizon" tier added in Phase 1B). + liquidity: | + Same portfolio profile as GRF; classification independent. + transparency: | + Same portfolio profile as GRF; reported to National Assembly + but sealed from public; partial IFSWF engagement. LM index = 6. + aum_pct_of_audited: | + FGF receives 10% of state revenue annually + accumulated + returns; in steady state holds ~95% of KIA's combined + audited AUM. The 5/95 split is per Kuwait's Public Funds + Law and is the canonical proportion published by KIA in + IFSWF disclosures. sources: - https://www.kia.gov.kw/en/ - https://www.ifswf.org/member-profiles/kuwait-investment-authority @@ -302,6 +502,304 @@ funds: - https://www.temasekreview.com.sg/ - https://www.temasek.com.sg/en/our-financials + # ── China ── + # Phase 1C addition (Plan 2026-04-25-001). Three CN sovereign-wealth + # vehicles tracked by SWFI/IFSWF; SAFE Investment Co is excluded + # from the buffer dim because its AUM is part of the SAFE umbrella + # consolidated FX reserves (already counted in reserveAdequacy / + # liquidReserveAdequacy). + - country: CN + fund: cic + display_name: China Investment Corporation (CIC) + wikipedia: + abbrev: CIC + fund_name: China Investment Corporation + aum_usd: 1350000000000 + aum_year: 2023 + aum_verified: true + classification: + access: 0.4 + liquidity: 0.5 + transparency: 0.3 + rationale: + access: | + Hybrid strategic-investment vehicle with PBOC + MOF + coordination required for major redeployment. Long-horizon + external mandate (CIC International) plus state-directed + domestic financial holdings (Central Huijin) — flexible in + principle but politically constrained. 0.4 mid-tier. + liquidity: | + ~50% public-market (listed equities + bonds via CIC + International), ~50% private (Central Huijin domestic banking + stakes, alternative investments). Mid-liquidity. + transparency: | + Annual report publishes AUM and asset-class summary; holdings- + level disclosure limited to large public stakes (13F-equivalent + for U.S. holdings). LM index ~3-4. + sources: + - http://www.china-inv.cn/en/ + - https://www.swfinstitute.org/profile/cic + + - country: CN + fund: nssf + display_name: National Council for Social Security Fund (NSSF) + wikipedia: + fund_name: National Council for Social Security Fund + aum_usd: 400000000000 + aum_year: 2023 + aum_verified: true + classification: + access: 0.20 + liquidity: 0.5 + transparency: 0.4 + rationale: + access: | + Pension-purpose: NSSF holdings are statutorily reserved for + social-security payment obligations. Withdrawals for + non-pension fiscal stabilization are not permitted under + current Chinese law. Maps to the 0.20 "statutorily-gated + long-horizon" tier added in Phase 1B (KIA-FGF analogue). + liquidity: | + Mix of listed equities, fixed income, and strategic + unlisted holdings (banking IPO seedings). Mid-liquidity. + transparency: | + Annual report publishes AUM totals + broad allocation; per- + holding disclosure limited. + sources: + - http://www.ssf.gov.cn/ + - https://www.swfinstitute.org/profile/nssf + + - country: CN + fund: safe-ic + display_name: SAFE Investment Company Limited + aum_usd: 417000000000 + aum_year: 2024 + aum_verified: true + classification: + access: 0.5 + liquidity: 0.7 + transparency: 0.3 + excluded_overlaps_with_reserves: true + rationale: + access: | + Subsidiary of the State Administration of Foreign Exchange + (SAFE); manages a portion of China's FX reserves abroad. + Documentation-only — see excluded rationale. + liquidity: | + Predominantly listed equities + sovereign bonds (FX-reserve- + like portfolio composition). + transparency: | + Limited public disclosure; AUM tracked via SWFI third-party + estimates. LM index ~2. + excluded_overlaps_with_reserves: | + SAFE Investment Co's AUM is part of China's State Administration + of Foreign Exchange consolidated reserves. Including it in the + SWF buffer would double-count against `reserveAdequacy` / + `liquidReserveAdequacy`, both of which already capture + SAFE-managed FX reserves. Documentation-only entry; excluded + from buffer scoring. + sources: + - https://www.swfinstitute.org/profile/safe-investment-company + + # ── Hong Kong ── + # HKMA Exchange Fund. Backing Portfolio is reserves-equivalent + # (already in reserveAdequacy); Investment Portfolio + Future Fund + # branch could be SWF-relevant but the consolidated AUM is + # reported as one figure. Excluded from buffer to avoid double- + # counting against HK monetary-authority reserves. + - country: HK + fund: hkma-ef + display_name: HKMA Exchange Fund + aum_usd: 498000000000 + aum_year: 2024 + aum_verified: true + classification: + access: 0.7 + liquidity: 0.9 + transparency: 0.7 + excluded_overlaps_with_reserves: true + rationale: + access: | + Statutory mandate: maintain HKD peg + back banking system. + High access for monetary stabilization; stabilization is the + primary mandate. Documentation-only — see excluded rationale. + liquidity: | + Predominantly listed equities + sovereign bonds + USD cash. + Highly liquid by design (Backing Portfolio is reserves). + transparency: | + HKMA publishes monthly Exchange Fund balance sheet + annual + report. LM index ~8. + excluded_overlaps_with_reserves: | + HKMA Exchange Fund's Backing Portfolio is reserves-equivalent + and is captured under `reserveAdequacy` / `liquidReserveAdequacy`. + Investment Portfolio + Future Fund are not separately disclosed + at AUM level. To avoid double-counting, excluded from buffer. + Implementation-Time Unknown #2 in the parent plan flagged this + for follow-up: when HKMA discloses Investment Portfolio AUM + separately, the Investment Portfolio sleeve could be added as + a non-excluded entry. + sources: + - https://www.hkma.gov.hk/eng/data-publications-and-research/ + + # ── South Korea ── + - country: KR + fund: kic + display_name: Korea Investment Corporation (KIC) + wikipedia: + abbrev: KIC + fund_name: Korea Investment Corporation + aum_usd: 182000000000 + aum_year: 2024 + aum_verified: true + classification: + access: 0.3 + liquidity: 0.7 + transparency: 0.7 + rationale: + access: | + Long-horizon mandate; KIC manages assets under MOEF + Bank + of Korea entrustment but does not have an explicit + stabilization mandate. Withdrawals require entrustment + agreement modification. Intergenerational tier. + liquidity: | + ~70% public-market (listed equities + sovereign + corporate + bonds), ~30% alternatives (private equity, real estate, + infrastructure, hedge funds). + transparency: | + IFSWF full member. Annual report published with detailed + asset allocation, returns, and partial holdings. LM index ~7. + sources: + - https://www.kic.kr/en/ + - https://www.ifswf.org/member-profiles/korea-investment-corporation + + # ── Oman ── + # OIA established 2020 by merging State General Reserve Fund (SGRF) + # + Oman Investment Fund (OIF). IFSWF member; rubric pre-flagged + # as "Shippable". Phase 1D addition. + - country: OM + fund: oia + display_name: Oman Investment Authority (OIA) + wikipedia: + abbrev: OIA + fund_name: Oman Investment Authority + aum_usd: 50000000000 + aum_year: 2024 + aum_verified: true + classification: + access: 0.5 + liquidity: 0.5 + transparency: 0.5 + rationale: + access: | + Hybrid mandate: SGRF (now part of OIA) historically funded + budget shortfalls during oil downturns; OIF (now part of OIA) + was the strategic vehicle. Combined OIA inherits both; + deployment for fiscal support is mechanically possible but + coordinated through Ministry of Finance. Mid-tier hybrid. + liquidity: | + Mixed: external public-market (managed by external managers) + + domestic strategic stakes + alternative investments. Mid. + transparency: | + OIA publishes annual review post-2020 merger; IFSWF full + member. LM index ~6. + sources: + - https://www.oia.gov.om/ + - https://www.ifswf.org/member-profiles/oman-investment-authority + + # ── Bahrain ── + - country: BH + fund: mumtalakat + display_name: Mumtalakat Holding Company + wikipedia: + fund_name: Mumtalakat Holding Company + aum_usd: 19000000000 + aum_year: 2024 + aum_verified: true + classification: + access: 0.4 + liquidity: 0.4 + transparency: 0.6 + rationale: + access: | + Bahraini state strategic-investment vehicle. Holdings + concentrated in domestic banking (BBK, NBB), aluminum + (ALBA), telecoms (Batelco). Deployable for fiscal support + via dividend flow but not via primary-asset liquidation + without disrupting strategic positions. + liquidity: | + Domestic strategic holdings + foreign-listed equity stakes. + Mid-low liquidity. + transparency: | + Audited annual report, asset-class disclosures. IFSWF + member. LM index ~7. + sources: + - https://www.mumtalakat.bh/en + - https://www.ifswf.org/member-profiles/mumtalakat-holding-company + + # ── Timor-Leste ── + # Petroleum Fund of Timor-Leste — rubric flagged as + # "high-transparency textbook fit". High-transparency fund + # benchmark; Banco Central de Timor-Leste publishes monthly + # statements + annual reports. Phase 1D addition. + - country: TL + fund: petroleum-fund + display_name: Petroleum Fund of Timor-Leste + aum_usd: 22000000000 + aum_year: 2024 + aum_verified: true + classification: + access: 0.7 + liquidity: 0.9 + transparency: 0.9 + rationale: + access: | + Norwegian-model petroleum fund: fiscal-rule-based annual + Estimated Sustainable Income (ESI) drawdown for budget + support. Statutorily codified deployment trigger; closer + to GPFG's rule-based stabilization than to KIA-FGF's + long-horizon lock. 0.7 explicit-stabilization tier. + liquidity: | + ~60% sovereign bonds, ~40% global equities. Highly liquid. + transparency: | + Monthly statements published by Banco Central de Timor-Leste; + annual report with full holdings disclosure. LM index ~9. + sources: + - https://www.bancocentral.tl/en/petroleum-fund + - https://www.swfinstitute.org/profile/petroleum-fund-of-timor-leste + + # ── Australia ── + - country: AU + fund: future-fund + display_name: Australian Future Fund + wikipedia: + fund_name: Future Fund (Australia) + aum_usd: 192000000000 + aum_year: 2024 + aum_verified: true + classification: + access: 0.3 + liquidity: 0.5 + transparency: 0.8 + rationale: + access: | + Established 2006 to fund Commonwealth of Australia + unfunded superannuation liabilities. Statutorily restricted + from drawdown until 2027 (originally — extended). Long- + horizon savings tier. Australian fiscal practice has used + Future Fund AUM as a buffer signal in budget discussions + but no operational drawdown has occurred for stabilization. + liquidity: | + Mixed: ~30% listed equities, ~25% alternatives, ~20% bonds, + ~15% real estate + infrastructure, ~10% private equity. + Mid-liquidity. + transparency: | + Quarterly portfolio updates with asset-class breakdowns; + annual report with detailed performance + holdings discussion. + LM index ~9. + sources: + - https://www.futurefund.gov.au/ + - https://www.swfinstitute.org/profile/australia-future-fund + # ──────────────────────────────────────────────────────────────────── # CANDIDATES DEFERRED FROM V1 # ──────────────────────────────────────────────────────────────────── diff --git a/scripts/shared/swf-manifest-loader.mjs b/scripts/shared/swf-manifest-loader.mjs index 34a35285a..348f20503 100644 --- a/scripts/shared/swf-manifest-loader.mjs +++ b/scripts/shared/swf-manifest-loader.mjs @@ -31,6 +31,23 @@ const MANIFEST_PATH = resolve(here, './swf-classification-manifest.yaml'); * @property {number} access 0..1 inclusive * @property {number} liquidity 0..1 inclusive * @property {number} transparency 0..1 inclusive + * @property {number} [aumPctOfAudited] OPTIONAL 0..1; multiplier applied + * to the matched audited AUM, used + * when one entry represents only a + * fraction of a combined audited + * fund (e.g. KIA-GRF vs KIA-FGF + * split of audited KIA AUM). + * @property {boolean} [excludedOverlapsWithReserves] OPTIONAL; when true, + * the seeder loads the entry for + * documentation but EXCLUDES it + * from buffer calculation. Used + * for funds whose AUM is already + * counted in central-bank FX + * reserves (SAFE Investment Co, + * HKMA Exchange Fund) to avoid + * double-counting against the + * reserveAdequacy / + * liquidReserveAdequacy dims. */ /** @@ -52,8 +69,20 @@ const MANIFEST_PATH = resolve(here, './swf-classification-manifest.yaml'); * @property {string} displayName human-readable fund name * @property {SwfWikipediaHints} [wikipedia] optional lookup hints for the * Wikipedia fallback scraper + * @property {number} [aumUsd] OPTIONAL primary-source AUM in USD. + * When present AND `aumVerified === true`, + * the seeder uses this value directly + * instead of resolving via Wikipedia. + * @property {number} [aumYear] OPTIONAL year of the primary-source + * AUM disclosure (e.g. 2024). + * @property {boolean} [aumVerified] OPTIONAL primary-source-confirmed flag. + * When false, the entry is loaded for + * documentation but EXCLUDED from buffer + * scoring (data-integrity rule). * @property {SwfClassification} classification - * @property {{ access: string, liquidity: string, transparency: string }} rationale + * @property {{ access: string, liquidity: string, transparency: string, + * [aum_pct_of_audited]: string, + * [excluded_overlaps_with_reserves]: string }} rationale * @property {string[]} sources */ @@ -93,7 +122,35 @@ function validateClassification(cls, path) { assertZeroToOne(c.access, `${path}.access`); assertZeroToOne(c.liquidity, `${path}.liquidity`); assertZeroToOne(c.transparency, `${path}.transparency`); - return { access: c.access, liquidity: c.liquidity, transparency: c.transparency }; + + // OPTIONAL: aum_pct_of_audited multiplier (KIA-GRF/FGF split case). + let aumPctOfAudited; + if (c.aum_pct_of_audited != null) { + if (typeof c.aum_pct_of_audited !== 'number' + || Number.isNaN(c.aum_pct_of_audited) + || c.aum_pct_of_audited <= 0 + || c.aum_pct_of_audited > 1) { + fail(`${path}.aum_pct_of_audited: expected number in (0, 1], got ${JSON.stringify(c.aum_pct_of_audited)}`); + } + aumPctOfAudited = c.aum_pct_of_audited; + } + + // OPTIONAL: excluded_overlaps_with_reserves flag (SAFE-IC / HKMA case). + let excludedOverlapsWithReserves; + if (c.excluded_overlaps_with_reserves != null) { + if (typeof c.excluded_overlaps_with_reserves !== 'boolean') { + fail(`${path}.excluded_overlaps_with_reserves: expected boolean, got ${JSON.stringify(c.excluded_overlaps_with_reserves)}`); + } + excludedOverlapsWithReserves = c.excluded_overlaps_with_reserves; + } + + return { + access: c.access, + liquidity: c.liquidity, + transparency: c.transparency, + ...(aumPctOfAudited != null ? { aumPctOfAudited } : {}), + ...(excludedOverlapsWithReserves != null ? { excludedOverlapsWithReserves } : {}), + }; } function validateRationale(rat, path) { @@ -102,7 +159,19 @@ function validateRationale(rat, path) { assertNonEmptyString(r.access, `${path}.access`); assertNonEmptyString(r.liquidity, `${path}.liquidity`); assertNonEmptyString(r.transparency, `${path}.transparency`); - return { access: r.access, liquidity: r.liquidity, transparency: r.transparency }; + // Optional rationale paragraphs for the new schema fields. Required + // ONLY when the corresponding classification field is present (paired + // with a rationale in validateFundEntry). + const out = { access: r.access, liquidity: r.liquidity, transparency: r.transparency }; + if (r.aum_pct_of_audited != null) { + assertNonEmptyString(r.aum_pct_of_audited, `${path}.aum_pct_of_audited`); + out.aumPctOfAudited = r.aum_pct_of_audited; + } + if (r.excluded_overlaps_with_reserves != null) { + assertNonEmptyString(r.excluded_overlaps_with_reserves, `${path}.excluded_overlaps_with_reserves`); + out.excludedOverlapsWithReserves = r.excluded_overlaps_with_reserves; + } + return out; } function validateSources(sources, path) { @@ -154,6 +223,19 @@ function validateFundEntry(raw, idx, seenFundKeys) { if (!raw || typeof raw !== 'object') fail(`${path}: expected object`); const f = /** @type {Record} */ (raw); + // Misplacement gate. `aum_pct_of_audited` and + // `excluded_overlaps_with_reserves` are CLASSIFICATION fields. + // If they appear at the top level of a fund entry, the loader + // rejects with a clear error rather than silently accepting the + // misplaced field (which would be ignored by the schema and + // produce wrong scoring). Codex Round 1 #4. + if (f.aum_pct_of_audited !== undefined) { + fail(`${path}: aum_pct_of_audited must be placed under classification:, not top-level`); + } + if (f.excluded_overlaps_with_reserves !== undefined) { + fail(`${path}: excluded_overlaps_with_reserves must be placed under classification:, not top-level`); + } + assertIso2(f.country, `${path}.country`); assertNonEmptyString(f.fund, `${path}.fund`); assertNonEmptyString(f.display_name, `${path}.display_name`); @@ -162,16 +244,59 @@ function validateFundEntry(raw, idx, seenFundKeys) { if (seenFundKeys.has(dedupeKey)) fail(`${path}: duplicate fund identifier ${dedupeKey}`); seenFundKeys.add(dedupeKey); + // OPTIONAL primary-source AUM fields. When `aum_verified === true` + // AND `aum_usd` present, the seeder uses these directly without + // querying Wikipedia. When `aum_verified === false`, the entry + // is loaded for documentation but EXCLUDED from buffer scoring + // (data-integrity rule from plan §Phase 1A). + let aumUsd; + if (f.aum_usd != null) { + if (typeof f.aum_usd !== 'number' || !Number.isFinite(f.aum_usd) || f.aum_usd <= 0) { + fail(`${path}.aum_usd: expected positive finite number, got ${JSON.stringify(f.aum_usd)}`); + } + aumUsd = f.aum_usd; + } + let aumYear; + if (f.aum_year != null) { + if (typeof f.aum_year !== 'number' || !Number.isInteger(f.aum_year) || f.aum_year < 2000 || f.aum_year > 2100) { + fail(`${path}.aum_year: expected integer year in [2000, 2100], got ${JSON.stringify(f.aum_year)}`); + } + aumYear = f.aum_year; + } + let aumVerified; + if (f.aum_verified != null) { + if (typeof f.aum_verified !== 'boolean') { + fail(`${path}.aum_verified: expected boolean, got ${JSON.stringify(f.aum_verified)}`); + } + aumVerified = f.aum_verified; + } + // Coherence: if aum_verified === true, both aum_usd and aum_year MUST be present. + // (A "verified" entry without an actual value is meaningless.) + if (aumVerified === true && (aumUsd == null || aumYear == null)) { + fail(`${path}: aum_verified=true requires both aum_usd and aum_year to be present`); + } + const classification = validateClassification(f.classification, `${path}.classification`); const rationale = validateRationale(f.rationale, `${path}.rationale`); const sources = validateSources(f.sources, `${path}.sources`); const wikipedia = validateWikipediaHints(f.wikipedia, `${path}.wikipedia`); + // Coherence: rationale MUST cover any classification field that is set. + if (classification.aumPctOfAudited != null && rationale.aumPctOfAudited == null) { + fail(`${path}.rationale.aum_pct_of_audited: required when classification.aum_pct_of_audited is set`); + } + if (classification.excludedOverlapsWithReserves === true && rationale.excludedOverlapsWithReserves == null) { + fail(`${path}.rationale.excluded_overlaps_with_reserves: required when classification.excluded_overlaps_with_reserves is true`); + } + return { country: f.country, fund: f.fund, displayName: f.display_name, ...(wikipedia ? { wikipedia } : {}), + ...(aumUsd != null ? { aumUsd } : {}), + ...(aumYear != null ? { aumYear } : {}), + ...(aumVerified != null ? { aumVerified } : {}), classification, rationale, sources, diff --git a/tests/swf-classification-manifest.test.mjs b/tests/swf-classification-manifest.test.mjs index f0643f7ea..3f696d6f1 100644 --- a/tests/swf-classification-manifest.test.mjs +++ b/tests/swf-classification-manifest.test.mjs @@ -33,20 +33,48 @@ describe('SWF classification manifest — shipped YAML', () => { ); }); - it('lists the first-release set of funds from plan §3.4', () => { + it('lists the first-release set of funds from plan §3.4 (KIA split per Phase 1B)', () => { + // Phase 1B (Plan 2026-04-25-001) split the original `KW:kia` entry + // into `KW:kia-grf` and `KW:kia-fgf` to correctly attribute GRF's + // 0.9 stabilization access to its ~5% sleeve and FGF's 0.20 + // statutorily-gated access to the remaining ~95%. Both identifiers + // are now required. const expected = new Set([ 'NO:gpfg', 'AE:adia', 'AE:mubadala', 'SA:pif', - 'KW:kia', + 'KW:kia-grf', + 'KW:kia-fgf', 'QA:qia', 'SG:gic', 'SG:temasek', ]); const actual = new Set(manifest.funds.map((f) => `${f.country}:${f.fund}`)); for (const required of expected) { - assert.ok(actual.has(required), `plan §3.4 required fund missing from manifest: ${required}`); + assert.ok(actual.has(required), `plan §3.4 + Phase 1B required fund missing from manifest: ${required}`); + } + }); + + it('Phase 1 (Plan 2026-04-25-001) expansion adds 12 new funds across 7 new + extended countries', () => { + // Phase 1 expansion: UAE adds ICD/ADQ/EIA (3); KW splits kia → kia-grf+kia-fgf + // (1 net since kia is dropped); CN adds CIC/NSSF/SAFE-IC (3); HK adds HKMA-EF + // (1); KR adds KIC (1); AU adds Future Fund (1); OM adds OIA (1); BH adds + // Mumtalakat (1); TL adds Petroleum Fund (1). Net new identifiers: 12 over + // the original 8 + 1 from KIA split. Manifest total ≥ 20. + const required = new Set([ + 'AE:icd', 'AE:adq', 'AE:eia', + 'CN:cic', 'CN:nssf', 'CN:safe-ic', + 'HK:hkma-ef', + 'KR:kic', + 'AU:future-fund', + 'OM:oia', + 'BH:mumtalakat', + 'TL:petroleum-fund', + ]); + const actual = new Set(manifest.funds.map((f) => `${f.country}:${f.fund}`)); + for (const r of required) { + assert.ok(actual.has(r), `Phase 1 expansion fund missing from manifest: ${r}`); } }); diff --git a/tests/swf-manifest-loader-schema-extension.test.mjs b/tests/swf-manifest-loader-schema-extension.test.mjs new file mode 100644 index 000000000..7269cb30f --- /dev/null +++ b/tests/swf-manifest-loader-schema-extension.test.mjs @@ -0,0 +1,387 @@ +// Schema-extension tests for swf-manifest-loader.mjs (Phase 1). +// +// Pins the new schema fields' canonical placement and rejection rules: +// - top-level (per-fund): aum_usd, aum_year, aum_verified +// - under classification: aum_pct_of_audited, excluded_overlaps_with_reserves +// +// Codex Round 1 #4 mandated a SINGLE canonical placement for each new +// field, with the loader REJECTING misplacement (positive control) +// rather than silently accepting it. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { validateManifest } from '../scripts/shared/swf-manifest-loader.mjs'; +import { + shouldSkipFundForBuffer, + applyAumPctOfAudited, + buildCoverageSummary, +} from '../scripts/seed-sovereign-wealth.mjs'; + +function makeFund(overrides = {}) { + return { + country: 'AE', + fund: 'test-fund', + display_name: 'Test Fund', + classification: { access: 0.5, liquidity: 0.5, transparency: 0.5 }, + rationale: { access: 'a', liquidity: 'l', transparency: 't' }, + sources: ['https://example.com/'], + ...overrides, + }; +} + +function makeManifest(funds) { + return { + manifest_version: 1, + last_reviewed: '2026-04-25', + external_review_status: 'REVIEWED', + funds, + }; +} + +test('REJECTS aum_pct_of_audited placed at fund top level (must be under classification)', () => { + const m = makeManifest([ + makeFund({ aum_pct_of_audited: 0.05 }), + ]); + assert.throws(() => validateManifest(m), /aum_pct_of_audited must be placed under classification/); +}); + +test('REJECTS excluded_overlaps_with_reserves placed at fund top level', () => { + const m = makeManifest([ + makeFund({ excluded_overlaps_with_reserves: true }), + ]); + assert.throws(() => validateManifest(m), /excluded_overlaps_with_reserves must be placed under classification/); +}); + +test('ACCEPTS aum_pct_of_audited under classification when paired with rationale', () => { + const m = makeManifest([ + makeFund({ + classification: { access: 0.9, liquidity: 0.8, transparency: 0.4, aum_pct_of_audited: 0.05 }, + rationale: { access: 'a', liquidity: 'l', transparency: 't', aum_pct_of_audited: 'GRF is ~5% of audited KIA AUM' }, + }), + ]); + const out = validateManifest(m); + assert.equal(out.funds[0].classification.aumPctOfAudited, 0.05); + assert.equal(out.funds[0].rationale.aumPctOfAudited, 'GRF is ~5% of audited KIA AUM'); +}); + +test('REJECTS aum_pct_of_audited under classification WITHOUT a rationale paragraph', () => { + const m = makeManifest([ + makeFund({ + classification: { access: 0.9, liquidity: 0.8, transparency: 0.4, aum_pct_of_audited: 0.05 }, + // rationale.aum_pct_of_audited is missing + }), + ]); + assert.throws(() => validateManifest(m), + /rationale\.aum_pct_of_audited: required when classification\.aum_pct_of_audited is set/); +}); + +test('REJECTS aum_pct_of_audited outside (0, 1] range', () => { + // `null` is intentionally NOT in this list — the loader treats null + // as "field absent" (the value is optional), which is correct. + for (const bad of [0, -0.1, 1.5, 'x', NaN]) { + const m = makeManifest([ + makeFund({ + classification: { access: 0.9, liquidity: 0.8, transparency: 0.4, aum_pct_of_audited: bad }, + }), + ]); + assert.throws(() => validateManifest(m), /aum_pct_of_audited: expected number in \(0, 1\]/); + } +}); + +test('ACCEPTS excluded_overlaps_with_reserves: true with paired rationale', () => { + const m = makeManifest([ + makeFund({ + classification: { access: 0.5, liquidity: 0.7, transparency: 0.3, excluded_overlaps_with_reserves: true }, + rationale: { access: 'a', liquidity: 'l', transparency: 't', excluded_overlaps_with_reserves: 'SAFE-IC overlaps PBOC reserves' }, + }), + ]); + const out = validateManifest(m); + assert.equal(out.funds[0].classification.excludedOverlapsWithReserves, true); +}); + +test('REJECTS excluded_overlaps_with_reserves: true WITHOUT rationale paragraph', () => { + const m = makeManifest([ + makeFund({ + classification: { access: 0.5, liquidity: 0.7, transparency: 0.3, excluded_overlaps_with_reserves: true }, + }), + ]); + assert.throws(() => validateManifest(m), + /rationale\.excluded_overlaps_with_reserves: required when classification\.excluded_overlaps_with_reserves is true/); +}); + +test('REJECTS excluded_overlaps_with_reserves of non-boolean type', () => { + const m = makeManifest([ + makeFund({ + classification: { access: 0.5, liquidity: 0.7, transparency: 0.3, excluded_overlaps_with_reserves: 'true' }, + }), + ]); + assert.throws(() => validateManifest(m), /excluded_overlaps_with_reserves: expected boolean/); +}); + +test('ACCEPTS aum_usd + aum_year + aum_verified=true together', () => { + const m = makeManifest([ + makeFund({ + aum_usd: 320_000_000_000, + aum_year: 2024, + aum_verified: true, + }), + ]); + const out = validateManifest(m); + assert.equal(out.funds[0].aumUsd, 320_000_000_000); + assert.equal(out.funds[0].aumYear, 2024); + assert.equal(out.funds[0].aumVerified, true); +}); + +test('REJECTS aum_verified: true without aum_usd', () => { + const m = makeManifest([ + makeFund({ + aum_verified: true, + aum_year: 2024, + }), + ]); + assert.throws(() => validateManifest(m), + /aum_verified=true requires both aum_usd and aum_year to be present/); +}); + +test('REJECTS aum_verified: true without aum_year', () => { + const m = makeManifest([ + makeFund({ + aum_verified: true, + aum_usd: 100_000_000_000, + }), + ]); + assert.throws(() => validateManifest(m), + /aum_verified=true requires both aum_usd and aum_year to be present/); +}); + +test('ACCEPTS aum_verified: false (entry loaded for documentation only)', () => { + // No aum_usd / aum_year required when verified=false — the entry + // documents an unverifiable fund that the seeder will skip from + // scoring. This is the EIA / data-integrity-rule path. + const m = makeManifest([ + makeFund({ + aum_verified: false, + }), + ]); + const out = validateManifest(m); + assert.equal(out.funds[0].aumVerified, false); + assert.equal(out.funds[0].aumUsd, undefined); +}); + +test('REJECTS aum_year out of [2000, 2100]', () => { + // `null` excluded — treated as field-absent, intentional. + for (const bad of [1999, 2101, 0, -1, 'x']) { + const m = makeManifest([ + makeFund({ + aum_usd: 100_000_000_000, + aum_year: bad, + aum_verified: true, + }), + ]); + assert.throws(() => validateManifest(m), /aum_year/); + } +}); + +test('REJECTS aum_usd of non-positive or non-finite type', () => { + // `null` excluded — treated as field-absent, intentional. + for (const bad of [0, -1, NaN, Infinity, 'big']) { + const m = makeManifest([ + makeFund({ + aum_usd: bad, + aum_year: 2024, + aum_verified: true, + }), + ]); + assert.throws(() => validateManifest(m), /aum_usd/); + } +}); + +test('Backward-compat: existing entries without new fields still validate', () => { + // The 8 existing entries on origin/main don't carry aum_usd / + // aum_pct / excluded flags. Ensure the schema extension is purely + // additive — existing fields produce a clean parse. + const m = makeManifest([makeFund()]); + const out = validateManifest(m); + assert.equal(out.funds[0].aumUsd, undefined); + assert.equal(out.funds[0].aumVerified, undefined); + assert.equal(out.funds[0].classification.aumPctOfAudited, undefined); + assert.equal(out.funds[0].classification.excludedOverlapsWithReserves, undefined); +}); + +// ── Seeder-side pure helpers ────────────────────────────────────── + +test('shouldSkipFundForBuffer: returns null for a normal fund', () => { + const fund = { classification: { access: 0.5 }, aumVerified: true }; + assert.equal(shouldSkipFundForBuffer(fund), null); +}); + +test('shouldSkipFundForBuffer: skips when excluded_overlaps_with_reserves=true', () => { + const fund = { + classification: { access: 0.5, excludedOverlapsWithReserves: true }, + aumVerified: true, + }; + assert.equal(shouldSkipFundForBuffer(fund), 'excluded_overlaps_with_reserves'); +}); + +test('shouldSkipFundForBuffer: skips when aum_verified=false', () => { + const fund = { + classification: { access: 0.5 }, + aumVerified: false, + }; + assert.equal(shouldSkipFundForBuffer(fund), 'aum_unverified'); +}); + +test('shouldSkipFundForBuffer: excluded takes precedence over unverified (single skip reason)', () => { + // If a fund is BOTH excluded (overlaps reserves) AND unverified, + // we surface the excluded reason because that's the more + // architectural concern (double-counting risk). + const fund = { + classification: { excludedOverlapsWithReserves: true }, + aumVerified: false, + }; + assert.equal(shouldSkipFundForBuffer(fund), 'excluded_overlaps_with_reserves'); +}); + +test('shouldSkipFundForBuffer: returns null when neither flag is set', () => { + // Backward-compat: existing entries on origin/main don't carry + // aumVerified or excludedOverlapsWithReserves. They must NOT skip. + assert.equal(shouldSkipFundForBuffer({ classification: { access: 0.5 } }), null); +}); + +test('shouldSkipFundForBuffer: handles malformed / null input defensively', () => { + assert.equal(shouldSkipFundForBuffer(null), null); + assert.equal(shouldSkipFundForBuffer(undefined), null); + assert.equal(shouldSkipFundForBuffer({}), null); +}); + +test('applyAumPctOfAudited: returns AUM unchanged when no multiplier set', () => { + const fund = { classification: { access: 0.5 } }; + assert.equal(applyAumPctOfAudited(1_000_000_000_000, fund), 1_000_000_000_000); +}); + +test('applyAumPctOfAudited: applies the fraction (KIA-GRF case)', () => { + // KIA combined audited AUM = $1.072T; GRF is ~5% + const fund = { classification: { access: 0.9, aumPctOfAudited: 0.05 } }; + const out = applyAumPctOfAudited(1_072_000_000_000, fund); + assert.equal(out, 53_600_000_000); +}); + +test('applyAumPctOfAudited: KIA-GRF + KIA-FGF sum equals combined AUM', () => { + // The split must be conservative — sum of fractional parts equals + // the original audited AUM. Pinned because a future edit that + // changes 5/95 split to e.g. 5/90 would silently drop $50B. + const audited = 1_072_000_000_000; + const grf = applyAumPctOfAudited(audited, { classification: { aumPctOfAudited: 0.05 } }); + const fgf = applyAumPctOfAudited(audited, { classification: { aumPctOfAudited: 0.95 } }); + assert.equal(grf + fgf, audited); +}); + +test('applyAumPctOfAudited: ignores out-of-range multipliers (defensive)', () => { + // The loader rejects out-of-range values at parse time; this is a + // belt-and-suspenders runtime check that doesn't multiply by an + // invalid fraction even if the loader's gate is somehow bypassed. + for (const bad of [0, -0.1, 1.5, NaN, 'big']) { + const fund = { classification: { aumPctOfAudited: bad } }; + assert.equal(applyAumPctOfAudited(1_000, fund), 1_000); + } +}); + +// ── buildCoverageSummary regression: completeness denominator ────── +// +// User's PR-3391 review caught a P1: completeness used `funds.length` +// (manifest count) as the denominator, which depresses the ratio for +// countries whose manifest contains documentation-only entries +// (excluded_overlaps_with_reserves OR aum_verified=false). The shipped +// manifest has this state for UAE (EIA unverified) and CN (SAFE-IC +// excluded). These tests pin the corrected denominator: only scorable +// funds count toward expected. + +test('buildCoverageSummary: country with all scorable funds matched is "complete" even if manifest also has unverified entries', () => { + // UAE-shape: 4 scorable (ADIA, Mubadala, ICD, ADQ) + 1 unverified (EIA). + // If all 4 scorable matched, country is COMPLETE, not partial. + const manifest = { + funds: [ + { country: 'AE', fund: 'adia', classification: { access: 0.4 } }, + { country: 'AE', fund: 'mubadala',classification: { access: 0.5 } }, + { country: 'AE', fund: 'icd', classification: { access: 0.5 } }, + { country: 'AE', fund: 'adq', classification: { access: 0.5 } }, + { country: 'AE', fund: 'eia', classification: { access: 0.4 }, aumVerified: false }, + ], + }; + const imports = { AE: { importsUsd: 481.9e9 } }; + const countries = { + AE: { + // expectedFunds is computed PER-COUNTRY in fetchSovereignWealth using + // shouldSkipFundForBuffer, so this test fixture mirrors the seeder's + // post-fix output (expectedFunds = 4 scorable, completeness = 1.0). + matchedFunds: 4, + expectedFunds: 4, + completeness: 1.0, + }, + }; + const summary = buildCoverageSummary(manifest, imports, countries); + // Only 4 scorable funds in AE; 1 unverified entry doesn't count. + assert.equal(summary.expectedFunds, 4, + `headline expected funds should exclude documentation-only entries; got ${summary.expectedFunds}`); + const aeStatus = summary.countryStatuses.find((s) => s.country === 'AE'); + assert.equal(aeStatus.status, 'complete'); +}); + +test('buildCoverageSummary: excludes excluded_overlaps_with_reserves entries from expectedFundsTotal', () => { + // CN-shape: CIC + NSSF scorable + SAFE-IC excluded. + const manifest = { + funds: [ + { country: 'CN', fund: 'cic', classification: { access: 0.4 } }, + { country: 'CN', fund: 'nssf', classification: { access: 0.20 } }, + { country: 'CN', fund: 'safe-ic', classification: { access: 0.5, excludedOverlapsWithReserves: true } }, + ], + }; + const imports = { CN: { importsUsd: 3.0e12 } }; + const countries = { + CN: { matchedFunds: 2, expectedFunds: 2, completeness: 1.0 }, + }; + const summary = buildCoverageSummary(manifest, imports, countries); + assert.equal(summary.expectedFunds, 2, + `SAFE-IC should NOT count toward expected funds; got ${summary.expectedFunds}`); + const cnStatus = summary.countryStatuses.find((s) => s.country === 'CN'); + assert.equal(cnStatus.status, 'complete'); +}); + +test('buildCoverageSummary: missing-country path uses scorable count, not raw manifest count', () => { + // Country with mixed scorable + excluded entries that fails to seed + // entirely (e.g. WB imports missing). The "expected" figure on the + // missing-country status row should reflect SCORABLE funds, not all + // manifest entries — otherwise an operator dashboard shows + // "0/3 funds" when the truth is "0/2 funds, 1 documentation-only". + const manifest = { + funds: [ + { country: 'CN', fund: 'cic', classification: { access: 0.4 } }, + { country: 'CN', fund: 'nssf', classification: { access: 0.20 } }, + { country: 'CN', fund: 'safe-ic', classification: { access: 0.5, excludedOverlapsWithReserves: true } }, + ], + }; + const imports = {}; // CN imports missing → country not seeded + const countries = {}; // no country payload at all + const summary = buildCoverageSummary(manifest, imports, countries); + const cnStatus = summary.countryStatuses.find((s) => s.country === 'CN'); + assert.equal(cnStatus.status, 'missing'); + assert.equal(cnStatus.expected, 2, + `missing-country expected should be SCORABLE count (2), not all-manifest (3); got ${cnStatus.expected}`); +}); + +test('buildCoverageSummary: country with ONLY documentation-only entries is excluded from expectedCountries', () => { + // Edge case: hypothetical country where every manifest entry is + // documentation-only (e.g. only EIA-style unverified). Such a + // country has 0 scorable funds → should not appear in + // expectedCountries because there's nothing scorable to expect. + const manifest = { + funds: [ + { country: 'XX', fund: 'placeholder', classification: { access: 0.4 }, aumVerified: false }, + ], + }; + const summary = buildCoverageSummary(manifest, {}, {}); + assert.equal(summary.expectedCountries, 0, + `XX has zero scorable funds — should not be in expectedCountries`); + assert.equal(summary.expectedFunds, 0); +});