/** * @fileoverview World Bank Poverty and Inequality Platform (PIP) service. Wraps * the `/pip` endpoint for economies and `/pip-grp` for PIP's regional, * income-group, and lending-group aggregates, neither of which shares an * envelope, a pagination model, or an error convention with the Indicators v2 * API: rows arrive as a flat JSON array, there is no server-side paging, and a * rejected parameter value comes back as a real HTTP 404 carrying the list of * values that would have been accepted. Each requested code is routed by PIP's * own regions table; economy survey rows are preferred over gap-filled ones and * the two are merged, because PIP strips the whole distributional block from * every gap-filled row; and economies PIP publishes only as model estimates, * which `/pip` rejects by code, are read from its all-economy response instead. * @module services/pip/pip-service */ import type { Context } from '@cyanheads/mcp-ts-core'; import type { AppConfig } from '@cyanheads/mcp-ts-core/config'; import type { StorageService } from '@cyanheads/mcp-ts-core/storage'; import type { PovertyRow } from './types.js'; export declare class PipService { private readonly baseUrl; private readonly referenceCacheTtlMs; /** * Reference listings — `/versions` and the two `/aux` tables — keyed by what * was fetched and cached as promises, so concurrent queries share one * request. Held on the instance, like the Indicators service's reference * caches, so tests and multiple instances stay isolated. */ private readonly referenceCache; constructor(_config: AppConfig, _storage: StorageService); private buildUrl; /** Fetch and parse one PIP response, with non-2xx statuses translated by `classify`. */ private fetchJson; /** * Fetch one `/pip` or `/pip-grp` response, mapping PIP's status codes to * domain errors. An error names `reported` — the codes sent, unless the * request stands in for others, as `all` does when read for the economies * PIP publishes only as model estimates. */ private fetchRows; /** Fetch one `/pip` pass for `codes`, survey-only or gap-filled; an error names `reported`. */ private economyPass; /** * Load a reference listing, from cache while it is fresh. A TTL of 0 disables * retention; a failed load is never served from cache to the next query. * * The load is shared by every query waiting on it, so it runs under a signal * of its own ({@link sharedLoadContext}) rather than the first caller's, and * each caller waits on it only until its own signal aborts. One caller * cancelling fails that caller alone, and a load every caller abandoned still * completes and caches for the next query. */ private loadReference; /** The `/versions` listing: every data release × PPP vintage. */ private loadVersions; /** * PIP's regions table, as aggregate code → grouping type. Read unpinned: it is * loaded alongside `/versions`, before a release is resolved, and its 27 * codes were identical across both vintages of release 20260922 and the * unpinned form. * * A table that fails to load degrades to an empty one rather than failing * the query: every code then goes to `/pip`, as it did before aggregates were * routed, so an economy still answers and an aggregate meets `/pip`'s own * 500. The failure is logged and not cached, so the next query asks again. */ private loadRegions; /** Every economy PIP publishes in `version`, those it publishes only as model estimates included. */ private loadEconomies; /** * Pin a query to one release and PPP vintage. The listing keeps older releases * that `/pip` no longer serves — they answer HTTP 500, every 2011-vintage * build among them — so the choice is confined to the newest * `release_version`, and a vintage that release was not built at is rejected * rather than sent. The newest release is found by its `YYYYMMDD` stamp, not * by the listing's order. */ private resolveVersion; /** * Economy estimates, preferring survey rows and filling in whatever they * leave uncovered. * * `fill_gaps=true` is not a superset of `fill_gaps=false`. It answers for * every year in a country's coverage window, but every row it returns drops * `gini`, `mld`, `polarization`, the ten decile shares, and `survey_year` — * including for years a survey does exist for. Asking upstream once with the * caller's `fill_gaps` value would therefore make the whole inequality half of * this tool permanently null. Asking for survey rows first and gap-filling * around them gives an agent the real distribution whenever one exists, and an * estimate labelled as such when it doesn't. * * What "uncovered" means depends on the request. A four-digit year is answered * once an economy has any survey row, and PIP returns the same row grain in * both modes for a year it surveyed, so gap-filling there is per economy. A * request spanning the whole window (`all`, or no `year`) is the opposite: PIP * surveys a handful of years and estimates every year around them, so an * economy with survey rows still has gaps between and after them, and * gap-filling is per row grain. `MRV` follows `fill_gaps` as PIP's own `MRV` * does: both passes always run, each economy resolves to the newest year * either answers — PIP's latest estimate year wherever it publishes one — and * a survey row wins over its gap-filled twin at that year and grain, so no * economy comes back at two different years. */ private surveyFirst; /** * Economy estimates for `codes`, answering the economies PIP publishes only * as model estimates (`CMD estimation`). `/pip` rejects those by code — its * accepted `country` list holds surveyed economies and aggregates only — but * serves their rows under `country=all`. So a country rejection is read * against that list: a code PIP's full economy list does not carry either is * `country_not_found`, and the rest are answered from one `country=all` * gap-filled request for the same year and filters, narrowed to them, while * the other codes take the ordinary survey-first path. With `fill_gaps` off * they have no rows to return and are reported instead. A surveyed-economy * request never reaches this path, so it costs nothing extra. */ private economyRows; /** * Aggregate estimates from `/pip-grp`, one request per `group_by` in use. * `/pip-grp` answers `year=MRV` with an empty array, so `MRV` asks for the * whole series and keeps each aggregate's newest year — its latest nowcast, * the same reading `MRV` has for an economy under `fill_gaps`. */ private groupRows; /** * Fetch poverty and inequality estimates for economies and aggregates, merged * into one list and paginated locally. * * Codes are resolved in one order: WDI's group spellings (`LMC`, `IDX`, …) * read as PIP's and duplicates collapse; PIP's regions table then routes each * aggregate to `/pip-grp` and rejects the ones this tool does not serve, * before any data request; every other code goes to `/pip`. The regions table * loads alongside `/versions` and is skipped for a request of `all` alone. * * Every request carries the same fully-qualified `version`, resolved once up * front, so survey rows, the estimates filled around them, and the aggregates * always come from one release at one PPP vintage. */ getPoverty(opts: { countries: string[]; year?: string; povertyLine?: number; welfareType?: string; reportingLevel?: string; pppVersion?: string; fillGaps: boolean; page: number; perPage: number; }, ctx: Context): Promise<{ rows: PovertyRow[]; total: number; page: number; pages: number; /** Page size actually served: the requested size, reduced to the page cap when larger. */ perPage: number; /** * Codes as queried — uppercased, respelled, and deduplicated in request * order — under the spelling a caller sends them ({@link listedCode}). */ countries: string[]; gapFilled: boolean; /** Economies PIP publishes only as model estimates, left out because `fillGaps` was off. */ modelOnly: string[]; pppVersion: string; releaseVersion: string; }>; } export declare function initPipService(config: AppConfig, storage: StorageService): void; export declare function getPipService(): PipService; //# sourceMappingURL=pip-service.d.ts.map