@cliwant/mcp-sam-gov 0.2.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +48 -16
  3. package/README.ko.md +48 -16
  4. package/README.md +279 -67
  5. package/dist/attachments.d.ts +63 -0
  6. package/dist/attachments.d.ts.map +1 -0
  7. package/dist/attachments.js +576 -0
  8. package/dist/attachments.js.map +1 -0
  9. package/dist/bls.d.ts +267 -0
  10. package/dist/bls.d.ts.map +1 -0
  11. package/dist/bls.js +1549 -0
  12. package/dist/bls.js.map +1 -0
  13. package/dist/cache.d.ts +42 -0
  14. package/dist/cache.d.ts.map +1 -0
  15. package/dist/cache.js +64 -0
  16. package/dist/cache.js.map +1 -0
  17. package/dist/census.d.ts +182 -0
  18. package/dist/census.d.ts.map +1 -0
  19. package/dist/census.js +576 -0
  20. package/dist/census.js.map +1 -0
  21. package/dist/ckan.d.ts +141 -0
  22. package/dist/ckan.d.ts.map +1 -0
  23. package/dist/ckan.js +393 -0
  24. package/dist/ckan.js.map +1 -0
  25. package/dist/clinicaltrials.d.ts +180 -0
  26. package/dist/clinicaltrials.d.ts.map +1 -0
  27. package/dist/clinicaltrials.js +730 -0
  28. package/dist/clinicaltrials.js.map +1 -0
  29. package/dist/cms.d.ts +140 -0
  30. package/dist/cms.d.ts.map +1 -0
  31. package/dist/cms.js +482 -0
  32. package/dist/cms.js.map +1 -0
  33. package/dist/coerce.d.ts +32 -0
  34. package/dist/coerce.d.ts.map +1 -0
  35. package/dist/coerce.js +50 -0
  36. package/dist/coerce.js.map +1 -0
  37. package/dist/datagov-catalog.d.ts +84 -0
  38. package/dist/datagov-catalog.d.ts.map +1 -0
  39. package/dist/datagov-catalog.js +233 -0
  40. package/dist/datagov-catalog.js.map +1 -0
  41. package/dist/datagov.d.ts +146 -0
  42. package/dist/datagov.d.ts.map +1 -0
  43. package/dist/datagov.js +689 -0
  44. package/dist/datagov.js.map +1 -0
  45. package/dist/datagovKey.d.ts +36 -0
  46. package/dist/datagovKey.d.ts.map +1 -0
  47. package/dist/datagovKey.js +60 -0
  48. package/dist/datagovKey.js.map +1 -0
  49. package/dist/datasource.d.ts +313 -0
  50. package/dist/datasource.d.ts.map +1 -0
  51. package/dist/datasource.js +551 -0
  52. package/dist/datasource.js.map +1 -0
  53. package/dist/disclosure.d.ts +55 -0
  54. package/dist/disclosure.d.ts.map +1 -0
  55. package/dist/disclosure.js +57 -0
  56. package/dist/disclosure.js.map +1 -0
  57. package/dist/ecfr.d.ts +4 -2
  58. package/dist/ecfr.d.ts.map +1 -1
  59. package/dist/ecfr.js +92 -17
  60. package/dist/ecfr.js.map +1 -1
  61. package/dist/echo.d.ts +143 -0
  62. package/dist/echo.d.ts.map +1 -0
  63. package/dist/echo.js +424 -0
  64. package/dist/echo.js.map +1 -0
  65. package/dist/edgar.d.ts +377 -0
  66. package/dist/edgar.d.ts.map +1 -0
  67. package/dist/edgar.js +2418 -0
  68. package/dist/edgar.js.map +1 -0
  69. package/dist/errors.d.ts +102 -0
  70. package/dist/errors.d.ts.map +1 -0
  71. package/dist/errors.js +247 -0
  72. package/dist/errors.js.map +1 -0
  73. package/dist/fac.d.ts +180 -0
  74. package/dist/fac.d.ts.map +1 -0
  75. package/dist/fac.js +416 -0
  76. package/dist/fac.js.map +1 -0
  77. package/dist/far.d.ts +170 -0
  78. package/dist/far.d.ts.map +1 -0
  79. package/dist/far.js +804 -0
  80. package/dist/far.js.map +1 -0
  81. package/dist/fdic.d.ts +599 -0
  82. package/dist/fdic.d.ts.map +1 -0
  83. package/dist/fdic.js +1624 -0
  84. package/dist/fdic.js.map +1 -0
  85. package/dist/federal-register.d.ts +139 -2
  86. package/dist/federal-register.d.ts.map +1 -1
  87. package/dist/federal-register.js +432 -15
  88. package/dist/federal-register.js.map +1 -1
  89. package/dist/fema.d.ts +181 -0
  90. package/dist/fema.d.ts.map +1 -0
  91. package/dist/fema.js +436 -0
  92. package/dist/fema.js.map +1 -0
  93. package/dist/fpds.d.ts +108 -0
  94. package/dist/fpds.d.ts.map +1 -0
  95. package/dist/fpds.js +519 -0
  96. package/dist/fpds.js.map +1 -0
  97. package/dist/gao.d.ts +64 -0
  98. package/dist/gao.d.ts.map +1 -0
  99. package/dist/gao.js +640 -0
  100. package/dist/gao.js.map +1 -0
  101. package/dist/govinfo.d.ts +111 -0
  102. package/dist/govinfo.d.ts.map +1 -0
  103. package/dist/govinfo.js +422 -0
  104. package/dist/govinfo.js.map +1 -0
  105. package/dist/grants.d.ts +27 -4
  106. package/dist/grants.d.ts.map +1 -1
  107. package/dist/grants.js +114 -11
  108. package/dist/grants.js.map +1 -1
  109. package/dist/gsa-csv.d.ts +249 -0
  110. package/dist/gsa-csv.d.ts.map +1 -0
  111. package/dist/gsa-csv.js +784 -0
  112. package/dist/gsa-csv.js.map +1 -0
  113. package/dist/integrity.d.ts +212 -0
  114. package/dist/integrity.d.ts.map +1 -0
  115. package/dist/integrity.js +707 -0
  116. package/dist/integrity.js.map +1 -0
  117. package/dist/meta.d.ts +165 -0
  118. package/dist/meta.d.ts.map +1 -0
  119. package/dist/meta.js +162 -0
  120. package/dist/meta.js.map +1 -0
  121. package/dist/nih.d.ts +117 -0
  122. package/dist/nih.d.ts.map +1 -0
  123. package/dist/nih.js +291 -0
  124. package/dist/nih.js.map +1 -0
  125. package/dist/nppes.d.ts +157 -0
  126. package/dist/nppes.d.ts.map +1 -0
  127. package/dist/nppes.js +648 -0
  128. package/dist/nppes.js.map +1 -0
  129. package/dist/nsf.d.ts +176 -0
  130. package/dist/nsf.d.ts.map +1 -0
  131. package/dist/nsf.js +554 -0
  132. package/dist/nsf.js.map +1 -0
  133. package/dist/nvd.d.ts +176 -0
  134. package/dist/nvd.d.ts.map +1 -0
  135. package/dist/nvd.js +912 -0
  136. package/dist/nvd.js.map +1 -0
  137. package/dist/ofac.d.ts +205 -0
  138. package/dist/ofac.d.ts.map +1 -0
  139. package/dist/ofac.js +919 -0
  140. package/dist/ofac.js.map +1 -0
  141. package/dist/pricing.d.ts +110 -0
  142. package/dist/pricing.d.ts.map +1 -0
  143. package/dist/pricing.js +843 -0
  144. package/dist/pricing.js.map +1 -0
  145. package/dist/sam-gov/client.d.ts +60 -2
  146. package/dist/sam-gov/client.d.ts.map +1 -1
  147. package/dist/sam-gov/client.js +320 -54
  148. package/dist/sam-gov/client.js.map +1 -1
  149. package/dist/sam-gov/index.d.ts +1 -1
  150. package/dist/sam-gov/index.d.ts.map +1 -1
  151. package/dist/sam-gov/index.js +1 -1
  152. package/dist/sam-gov/index.js.map +1 -1
  153. package/dist/sam-gov/types.d.ts +24 -0
  154. package/dist/sam-gov/types.d.ts.map +1 -1
  155. package/dist/sba.d.ts +72 -0
  156. package/dist/sba.d.ts.map +1 -0
  157. package/dist/sba.js +281 -0
  158. package/dist/sba.js.map +1 -0
  159. package/dist/server.d.ts +14 -2
  160. package/dist/server.d.ts.map +1 -1
  161. package/dist/server.js +3897 -295
  162. package/dist/server.js.map +1 -1
  163. package/dist/snapshot.d.ts +98 -0
  164. package/dist/snapshot.d.ts.map +1 -0
  165. package/dist/snapshot.js +146 -0
  166. package/dist/snapshot.js.map +1 -0
  167. package/dist/socrata.d.ts +157 -0
  168. package/dist/socrata.d.ts.map +1 -0
  169. package/dist/socrata.js +448 -0
  170. package/dist/socrata.js.map +1 -0
  171. package/dist/treasury.d.ts +143 -0
  172. package/dist/treasury.d.ts.map +1 -0
  173. package/dist/treasury.js +436 -0
  174. package/dist/treasury.js.map +1 -0
  175. package/dist/usaspending.d.ts +260 -65
  176. package/dist/usaspending.d.ts.map +1 -1
  177. package/dist/usaspending.js +1664 -228
  178. package/dist/usaspending.js.map +1 -1
  179. package/dist/usitc.d.ts +142 -0
  180. package/dist/usitc.d.ts.map +1 -0
  181. package/dist/usitc.js +339 -0
  182. package/dist/usitc.js.map +1 -0
  183. package/package.json +24 -2
  184. package/src/attachments.ts +652 -0
  185. package/src/bls.ts +1943 -0
  186. package/src/cache.ts +73 -0
  187. package/src/census.ts +735 -0
  188. package/src/ckan.ts +495 -0
  189. package/src/clinicaltrials.ts +923 -0
  190. package/src/cms.ts +634 -0
  191. package/src/coerce.ts +47 -0
  192. package/src/datagov-catalog.ts +296 -0
  193. package/src/datagov.ts +907 -0
  194. package/src/datagovKey.ts +68 -0
  195. package/src/datasource.ts +721 -0
  196. package/src/disclosure.ts +61 -0
  197. package/src/ecfr.ts +231 -127
  198. package/src/echo.ts +496 -0
  199. package/src/edgar.ts +3014 -0
  200. package/src/errors.ts +303 -0
  201. package/src/fac.ts +529 -0
  202. package/src/far.ts +1007 -0
  203. package/src/fdic.ts +2052 -0
  204. package/src/federal-register.ts +706 -191
  205. package/src/fema.ts +541 -0
  206. package/src/fpds.ts +620 -0
  207. package/src/gao.ts +744 -0
  208. package/src/govinfo.ts +497 -0
  209. package/src/grants.ts +290 -155
  210. package/src/gsa-csv.ts +992 -0
  211. package/src/integrity.ts +928 -0
  212. package/src/meta.ts +292 -0
  213. package/src/nih.ts +375 -0
  214. package/src/nppes.ts +834 -0
  215. package/src/nsf.ts +706 -0
  216. package/src/nvd.ts +1124 -0
  217. package/src/ofac.ts +1166 -0
  218. package/src/pricing.ts +1075 -0
  219. package/src/sam-gov/client.ts +345 -63
  220. package/src/sam-gov/index.ts +5 -1
  221. package/src/sam-gov/types.ts +22 -0
  222. package/src/sba.ts +357 -0
  223. package/src/server.ts +4559 -327
  224. package/src/snapshot.ts +192 -0
  225. package/src/socrata.ts +532 -0
  226. package/src/treasury.ts +575 -0
  227. package/src/usaspending.ts +2680 -925
  228. package/src/usitc.ts +420 -0
package/src/pricing.ts ADDED
@@ -0,0 +1,1075 @@
1
+ /**
2
+ * Pricing tier (keyless) — labor-rate grounding for federal-contract pricing.
3
+ *
4
+ * Three keyless tools, every endpoint LIVE-VERIFIED 2026-07-03:
5
+ * - sam_search_wage_determinations → SAM SGS search (index=sca|dbra)
6
+ * - sam_get_wage_rates → SAM WDOL detail (rates parsed from TEXT)
7
+ * - gsa_benchmark_labor_rates → GSA CALC v3 ceiling-rate distribution
8
+ *
9
+ * The defining constraint of the two SAM tools: the actual wage RATES live
10
+ * inside a plain-text `document` blob (a fixed-width wage-determination form),
11
+ * NOT structured JSON. So `sam_get_wage_rates` parses best-effort and ALWAYS
12
+ * exposes a `parseConfidence` + a `format:"parsed"|"raw"|"both"` escape hatch so
13
+ * an AI can read the raw text when parsing is low-confidence. Truthfulness is
14
+ * the product: we never fake structure and we disclose every caveat in `_meta`.
15
+ *
16
+ * The defining constraint of the CALC tool: it returns a DISTRIBUTION of
17
+ * awarded CEILING (catalog) rates that are FULLY BURDENED — never a single
18
+ * "the rate". Its total count SATURATES at 10000 (relation:"gte") for broad
19
+ * queries. It honors page_size up to at least 200 (we request 100 rows/page),
20
+ * and its rows are GLOBALLY sorted ASCENDING by current_price. So when the exact
21
+ * total is KNOWN we read the true min/median/max at the quantile RANKS of that
22
+ * sorted index (exact stats over all matches, from a few targeted pages — NOT a
23
+ * leading subsample); only when the count is SATURATED (true total unknown) do we
24
+ * fall back to a leading-rows sample and DISCLOSE median/max as a downward-biased
25
+ * lower bound. A drifted CALC envelope (no hits{} / hits.hits not an array / no
26
+ * numeric total) THROWS schema_drift rather than fabricating an empty distribution.
27
+ */
28
+
29
+ import { fetchWithRetry, ToolErrorCarrier } from "./errors.js";
30
+ import { withMeta } from "./meta.js";
31
+
32
+ // ─── Shared HTTP ─────────────────────────────────────────────────
33
+
34
+ // SAM's public SGS/WDOL endpoints gate on a browser-y User-Agent AND require
35
+ // `Accept: application/hal+json` (SCA detail can 406 without it). Mirror the
36
+ // SamGovClient's UA so behavior is consistent across the server.
37
+ const SAM_UA =
38
+ "Mozilla/5.0 (compatible; @cliwant/mcp-sam-gov; +https://github.com/cliwant/mcp-sam-gov)";
39
+ const SAM_HAL_HEADERS = {
40
+ Accept: "application/hal+json",
41
+ "User-Agent": SAM_UA,
42
+ } as const;
43
+
44
+ const SGS_BASE = "https://sam.gov/api/prod/sgs/v1/search";
45
+ const WDOL_BASE = "https://sam.gov/api/prod/wdol/v1/wd";
46
+ const CALC_BASE =
47
+ "https://api.gsa.gov/acquisition/calc/v3/api/ceilingrates/";
48
+
49
+ async function getJson<T>(
50
+ url: string,
51
+ headers: Record<string, string>,
52
+ label: string,
53
+ timeoutMs = 15_000,
54
+ ): Promise<T> {
55
+ const r = await fetchWithRetry(
56
+ url,
57
+ { headers, signal: AbortSignal.timeout(timeoutMs) },
58
+ label,
59
+ );
60
+ return (await r.json()) as T;
61
+ }
62
+
63
+ // ─── Coverage normalization ──────────────────────────────────────
64
+
65
+ /**
66
+ * Map the user-facing coverage enum to the SGS `index`. CRITICAL: the API
67
+ * accepts `sca` and `dbra` — a literal `dba` returns HTTP 400, so we normalize
68
+ * `dba`→`dbra` (LIVE-VERIFIED 2026-07-03).
69
+ */
70
+ function coverageToIndex(coverage: string): "sca" | "dbra" {
71
+ const c = coverage.trim().toLowerCase();
72
+ if (c === "sca") return "sca";
73
+ // dba, dbra, davis-bacon → dbra
74
+ return "dbra";
75
+ }
76
+
77
+ // ─── 1. sam_search_wage_determinations ───────────────────────────
78
+
79
+ const WD_SEARCH_SOURCE = "sam.gov/sgs/v1 search (keyless HAL)";
80
+
81
+ type SgsStateSca = {
82
+ code?: string;
83
+ name?: string;
84
+ isStateWide?: boolean;
85
+ counties?: {
86
+ include?: { code?: number | string; value?: string }[] | null;
87
+ exclude?: { code?: number | string; value?: string }[] | null;
88
+ } | null;
89
+ };
90
+
91
+ type SgsResult = {
92
+ fullReferenceNumber?: string;
93
+ shortReferenceNumber?: string;
94
+ revisionNumber?: number;
95
+ type?: { code?: string; value?: string };
96
+ title?: string;
97
+ isActive?: boolean;
98
+ isStandard?: boolean;
99
+ publishDate?: number | string;
100
+ modifiedDate?: number | string;
101
+ year?: number;
102
+ rollover?: boolean;
103
+ constructionTypes?: unknown;
104
+ services?: { code?: string; value?: string }[];
105
+ allReferenceNumbers?: { wdNumber?: string }[];
106
+ // SCA: location.states[]; DBA: location.state (single) + counties[]
107
+ location?: {
108
+ states?: SgsStateSca[];
109
+ state?: {
110
+ code?: string;
111
+ name?: string;
112
+ counties?: { code?: number | string; value?: string }[] | null;
113
+ };
114
+ additionalInfo?: unknown;
115
+ };
116
+ };
117
+
118
+ type SgsSearchResp = {
119
+ _embedded?: { results?: SgsResult[] };
120
+ page?: {
121
+ size?: number;
122
+ totalElements?: number;
123
+ totalPages?: number;
124
+ number?: number;
125
+ maxAllowedRecords?: number;
126
+ };
127
+ };
128
+
129
+ /** Normalize a state code from either the SCA or DBA location shape. */
130
+ function resultStateCodes(r: SgsResult): string[] {
131
+ const codes = new Set<string>();
132
+ for (const s of r.location?.states ?? []) {
133
+ if (s.code) codes.add(String(s.code).toUpperCase());
134
+ }
135
+ const single = r.location?.state?.code;
136
+ if (single) codes.add(String(single).toUpperCase());
137
+ return [...codes];
138
+ }
139
+
140
+ /** Normalize the county list (name + code) from either shape. */
141
+ function resultCounties(r: SgsResult): { code: string; name: string }[] {
142
+ const out: { code: string; name: string }[] = [];
143
+ for (const s of r.location?.states ?? []) {
144
+ for (const c of s.counties?.include ?? []) {
145
+ if (c.value) out.push({ code: String(c.code ?? ""), name: c.value });
146
+ }
147
+ }
148
+ for (const c of r.location?.state?.counties ?? []) {
149
+ if (c.value) out.push({ code: String(c.code ?? ""), name: c.value });
150
+ }
151
+ return out;
152
+ }
153
+
154
+ function epochToIso(v: number | string | undefined): string | null {
155
+ if (v === undefined || v === null) return null;
156
+ const n = typeof v === "number" ? v : Number(v);
157
+ if (Number.isFinite(n)) {
158
+ // SGS publish/modified dates are epoch ms.
159
+ const d = new Date(n);
160
+ if (!Number.isNaN(d.getTime())) return d.toISOString().slice(0, 10);
161
+ }
162
+ // Some fields already come as YYYY-MM-DD.
163
+ if (typeof v === "string" && /^\d{4}-\d{2}-\d{2}/.test(v)) return v.slice(0, 10);
164
+ return null;
165
+ }
166
+
167
+ export async function searchWageDeterminations(args: {
168
+ coverage: string;
169
+ state?: string;
170
+ county?: string;
171
+ query?: string;
172
+ activeOnly?: boolean;
173
+ standardOnly?: boolean;
174
+ limit?: number;
175
+ page?: number;
176
+ }) {
177
+ const index = coverageToIndex(args.coverage);
178
+ const activeOnly = args.activeOnly ?? true;
179
+ const standardOnly = args.standardOnly ?? true;
180
+ const limit = Math.min(50, Math.max(1, Math.floor(args.limit ?? 20)));
181
+ const page = Math.max(0, Math.floor(args.page ?? 0));
182
+ const stateFilter = args.state?.trim().toUpperCase() || undefined;
183
+ const countyFilter = args.county?.trim().toLowerCase() || undefined;
184
+
185
+ const params = new URLSearchParams({
186
+ index,
187
+ size: String(limit),
188
+ page: String(page),
189
+ mode: "search",
190
+ sort: "-modifiedDate",
191
+ });
192
+ if (activeOnly) params.set("is_active", "true");
193
+ if (standardOnly) params.set("is_standard", "true");
194
+ // `q` matches WD number/title ONLY (NOT occupation) — verified q=guard→0.
195
+ if (args.query) params.set("q", args.query);
196
+ // `state` IS honored server-side (LIVE-VERIFIED 2026-07-03: index=sca&state=VA
197
+ // → 30 vs 1028 unfiltered, and every returned WD's location contains VA;
198
+ // state=ZZ → 0). It wants the 2-letter USPS code (a full name like "Virginia"
199
+ // → 0). This CORRECTS the earlier brief which assumed state was ignored.
200
+ const filtersApplied: string[] = [`coverage(${index})`];
201
+ const filtersDropped: string[] = [];
202
+ const notes: string[] = [];
203
+ if (activeOnly) filtersApplied.push("activeOnly");
204
+ if (standardOnly) filtersApplied.push("standardOnly");
205
+ if (args.query) filtersApplied.push("query(WD number/title only)");
206
+ if (stateFilter) {
207
+ if (/^[A-Z]{2}$/.test(stateFilter)) {
208
+ params.set("state", stateFilter);
209
+ filtersApplied.push("state(server-side)");
210
+ } else {
211
+ // Not a 2-letter code → the server would return 0; apply client-side
212
+ // instead so a full name still works, and disclose it.
213
+ filtersDropped.push("state(server-side; not a 2-letter code)");
214
+ notes.push(
215
+ `The state value '${args.state}' is not a 2-letter USPS code; the SGS 'state' param only matches 2-letter codes (a full name returns 0), so it was applied CLIENT-SIDE over the fetched page instead. Pass a 2-letter code (e.g. 'VA') for a precise server-side filter.`,
216
+ );
217
+ }
218
+ }
219
+
220
+ const url = `${SGS_BASE}?${params.toString()}`;
221
+ const json = await getJson<SgsSearchResp>(url, SAM_HAL_HEADERS, `sam:sgs:${index}`);
222
+ const rawResults = json._embedded?.results ?? [];
223
+ const serverTotal = json.page?.totalElements ?? null;
224
+
225
+ // Client-side filtering: county is NOT a documented SGS server param, so it
226
+ // is applied here over the fetched page. A non-2-letter state also lands here.
227
+ const needsClientState = filtersDropped.some((f) => f.startsWith("state"));
228
+ let filtered = rawResults;
229
+ if (needsClientState && stateFilter) {
230
+ filtered = filtered.filter((r) =>
231
+ resultStateCodes(r).some((c) => c === stateFilter || r.location?.state?.name?.toUpperCase() === stateFilter),
232
+ );
233
+ }
234
+ if (countyFilter) {
235
+ filtersApplied.push("county(client-side)");
236
+ filtered = filtered.filter((r) =>
237
+ resultCounties(r).some((c) => c.name.toLowerCase().includes(countyFilter)),
238
+ );
239
+ notes.push(
240
+ "County is filtered CLIENT-SIDE over the fetched page only (the SGS API has no county filter). A county filter combined with a small limit can miss WDs on later pages — raise `limit` or narrow by `state` (server-side) to be sure.",
241
+ );
242
+ }
243
+
244
+ const determinations = filtered.map((r) => {
245
+ const coverageCode = r.type?.code ?? (index === "sca" ? "SCA" : "DBA");
246
+ return {
247
+ fullReferenceNumber: r.fullReferenceNumber ?? "",
248
+ shortReferenceNumber: r.shortReferenceNumber ?? null,
249
+ revisionNumber: r.revisionNumber ?? null,
250
+ coverage: coverageCode, // "SCA" | "DBA"
251
+ title: r.title ?? null,
252
+ isActive: r.isActive ?? null,
253
+ isStandard: r.isStandard ?? null,
254
+ publishDate: epochToIso(r.publishDate),
255
+ modifiedDate: epochToIso(r.modifiedDate),
256
+ states: resultStateCodes(r),
257
+ counties: resultCounties(r).map((c) => c.name),
258
+ // SCA-only extras when present.
259
+ services: (r.services ?? []).map((s) => s.value ?? s.code ?? "").filter(Boolean),
260
+ // DBA-only extras.
261
+ year: r.year ?? null,
262
+ constructionTypes: r.constructionTypes ?? null,
263
+ allReferenceNumbers: (r.allReferenceNumbers ?? [])
264
+ .map((a) => a.wdNumber)
265
+ .filter((x): x is string => Boolean(x)),
266
+ };
267
+ });
268
+
269
+ notes.push(
270
+ `Use sam_get_wage_rates with a fullReferenceNumber + revisionNumber to read the actual prevailing-wage rate table (the rates are parsed from the WD's plain-text document). ${index === "sca" ? "SCA WDs carry a WD-wide Health & Welfare rate." : "DBA WDs carry PER-CRAFT fringe rates."}`,
271
+ );
272
+ notes.push(
273
+ "The `q` parameter matches the WD number/title only — it does NOT search by occupation or job title (e.g. q=guard returns 0). To find rates for a specific occupation, open the WD and read its rate table.",
274
+ );
275
+
276
+ // totalAvailable: the server total is REAL for the coverage/active/standard/
277
+ // query/state filters (state is server-side). But when we additionally filter
278
+ // client-side (county, or a non-code state), the returned page count no longer
279
+ // reflects a full server total for THAT combined filter → null it out and say so.
280
+ const clientFiltered = Boolean(countyFilter) || needsClientState;
281
+ const totalAvailable = clientFiltered ? null : serverTotal;
282
+ const returned = determinations.length;
283
+ const truncated = clientFiltered
284
+ ? true // page-bounded client filter → can't prove completeness
285
+ : serverTotal !== null && page * limit + returned < serverTotal;
286
+
287
+ if (clientFiltered) {
288
+ notes.push(
289
+ "totalAvailable is null because a client-side filter (county and/or a non-code state) was applied over just the fetched page — the true match count for the combined filter is unknown. The server-side total for the coverage/state/active filters was " +
290
+ (serverTotal ?? "unknown") +
291
+ ".",
292
+ );
293
+ }
294
+
295
+ return withMeta(
296
+ { determinations, coverageIndex: index, page, limit },
297
+ {
298
+ source: WD_SEARCH_SOURCE,
299
+ keylessMode: true,
300
+ returned,
301
+ totalAvailable,
302
+ truncated,
303
+ pagination: {
304
+ offset: page * limit,
305
+ limit,
306
+ nextOffset: truncated ? (page + 1) * limit : null,
307
+ hasMore: truncated,
308
+ },
309
+ filtersApplied,
310
+ filtersDropped,
311
+ fieldsUnavailable: [],
312
+ notes,
313
+ },
314
+ );
315
+ }
316
+
317
+ // ─── 2. sam_get_wage_rates ───────────────────────────────────────
318
+
319
+ const WD_DETAIL_SOURCE = "sam.gov/wdol/v1 wd detail (keyless HAL)";
320
+
321
+ type WdDetailResp = {
322
+ fullReferenceNumber?: string;
323
+ revisionNumber?: number;
324
+ location?: {
325
+ mapping?: { state?: string; counties?: (number | string)[]; statewideFlag?: boolean }[];
326
+ };
327
+ document?: string;
328
+ constructionType?: string;
329
+ shortName?: string;
330
+ year?: number;
331
+ publishDate?: string;
332
+ active?: boolean;
333
+ standard?: boolean;
334
+ };
335
+
336
+ type WdHistoryResp = {
337
+ _embedded?: {
338
+ wageDetermination?: {
339
+ fullReferenceNumber?: string;
340
+ revisionNumber?: number;
341
+ publishDate?: string;
342
+ active?: boolean;
343
+ standard?: boolean;
344
+ }[];
345
+ };
346
+ };
347
+
348
+ /** Resolve the latest ACTIVE revision for a WD reference via /history. */
349
+ async function latestActiveRevision(
350
+ fullReferenceNumber: string,
351
+ ): Promise<number | null> {
352
+ const url = `${WDOL_BASE}/${encodeURIComponent(fullReferenceNumber)}/history`;
353
+ const json = await getJson<WdHistoryResp>(url, SAM_HAL_HEADERS, "sam:wdol:history");
354
+ const revs = json._embedded?.wageDetermination ?? [];
355
+ // Newest-first per the API; prefer the newest active, else the newest overall.
356
+ const active = revs.find((r) => r.active && typeof r.revisionNumber === "number");
357
+ if (active?.revisionNumber !== undefined) return active.revisionNumber;
358
+ const any = revs.find((r) => typeof r.revisionNumber === "number");
359
+ return any?.revisionNumber ?? null;
360
+ }
361
+
362
+ /**
363
+ * Parse an SCA `document` blob into rate rows + WD-wide H&W + EO minimum.
364
+ *
365
+ * SCA fixed-width lines look like:
366
+ * `01011 - Accounting Clerk I 22.78`
367
+ * (5-digit occupation code, title, trailing hourly base rate). Section headers
368
+ * like `01000 - Administrative Support ...` have NO trailing rate → skipped.
369
+ * H&W is WD-wide: `HEALTH & WELFARE: $5.55 per hour ...`. The EO floor is read
370
+ * from whichever EO the text cites (13658 or 14026) — never hardcoded.
371
+ */
372
+ function parseScaDocument(doc: string) {
373
+ const lines = doc.split(/\r?\n/);
374
+ const rates: {
375
+ code: string;
376
+ title: string;
377
+ baseRate: number;
378
+ footnotes: number[] | null;
379
+ }[] = [];
380
+ // `01011 - Title .......... 22.78` → code, title, rate (rate is the LAST
381
+ // number on the line; titles never end in a bare decimal).
382
+ const rateRe = /^\s*(\d{5})\s*-\s*(.+?)\s+(\d{1,3}\.\d{2})\s*$/;
383
+ for (const line of lines) {
384
+ const m = rateRe.exec(line);
385
+ if (m && m[1] && m[2] && m[3]) {
386
+ // Real SCA WDs carry a FOOTNOTE column: an occupation with special pay
387
+ // rules shows "(see N)" between the title and the rate (e.g. Weather
388
+ // Observers → night/Sunday differential; Computer Employees → FLSA
389
+ // exemption). The footnote MATERIALLY changes pay, so extract it as a
390
+ // structured signal and strip it from the title (collapsing the column
391
+ // whitespace) rather than letting "(see 2)" + a run of spaces pollute the
392
+ // occupation name. (Synthetic fixtures lacked this; found via real-WD replay.)
393
+ const footnotes: number[] = [];
394
+ const title = m[2]
395
+ .replace(/\(see\s*(\d+)\)/gi, (_full, n) => {
396
+ footnotes.push(Number(n));
397
+ return " ";
398
+ })
399
+ .replace(/\.+$/, "")
400
+ .replace(/\s+/g, " ")
401
+ .trim();
402
+ rates.push({
403
+ code: m[1],
404
+ title,
405
+ baseRate: Number(m[3]),
406
+ footnotes: footnotes.length ? footnotes : null,
407
+ });
408
+ }
409
+ }
410
+
411
+ // WD-wide Health & Welfare — prefer the plain "HEALTH & WELFARE: $X" line
412
+ // (there is also a separate "HEALTH & WELFARE EO 13706: $Y" sick-leave line;
413
+ // capture the primary one, not the EO-13706 variant).
414
+ let healthAndWelfarePerHour: number | null = null;
415
+ for (const line of lines) {
416
+ const hw = /HEALTH\s*&\s*WELFARE\s*:\s*\$?\s*(\d+\.\d{2})\s*per hour/i.exec(line);
417
+ if (hw && hw[1]) {
418
+ healthAndWelfarePerHour = Number(hw[1]);
419
+ break;
420
+ }
421
+ }
422
+
423
+ const eo = parseExecutiveOrderMinimum(doc);
424
+
425
+ // Confidence: high when we found a plausible number of coded rates AND a H&W.
426
+ const parseConfidence: "high" | "low" =
427
+ rates.length >= 5 && healthAndWelfarePerHour !== null ? "high" : "low";
428
+
429
+ return { rates, healthAndWelfarePerHour, executiveOrderMinimum: eo, parseConfidence };
430
+ }
431
+
432
+ /**
433
+ * Parse a DBA `document` blob into per-craft rate + fringe rows.
434
+ *
435
+ * DBA lines carry two money columns under a `Rates Fringes` header:
436
+ * `ELECTRICIAN......................$ 34.50 10.81`
437
+ * BUT the craft label frequently WRAPS across several lines (a long scope
438
+ * description), and only the LAST line of the wrap carries the `$ rate fringe`:
439
+ * `BRICKLAYER/STONE MASON: ZONE 1 (The Counties of `
440
+ * `Polk, Warren, and Dallas for all Crafts, and Linn `
441
+ * `County Carpenters only.)............................$ 37.44 19.17`
442
+ * So we ACCUMULATE the non-rate lines and, when a rate line closes a block,
443
+ * join them into the full label. We also expose a short `craft` (the class name
444
+ * before the first ':' or '(') so the AI has a clean occupation name. DBA fringe
445
+ * is PER-CRAFT (contrast SCA's single WD-wide H&W). A rate-identifier header
446
+ * (e.g. "SAIA2026-001", "CARP1319") is tracked as `rateIdentifier`.
447
+ */
448
+ function parseDbaDocument(doc: string) {
449
+ const lines = doc.split(/\r?\n/);
450
+ const rates: {
451
+ craft: string;
452
+ title: string;
453
+ baseRate: number;
454
+ fringePerHour: number | null;
455
+ rateIdentifier: string | null;
456
+ }[] = [];
457
+ // The rate is the LAST money column(s) on the closing line.
458
+ const rateLineRe = /^(.*?)\.*\$\s*(\d{1,3}\.\d{2})(?:\s+(\d{1,3}\.\d{2}))?\s*$/;
459
+ // A union/rate identifier header: e.g. "CARP1319", "SAIA2026-001", "PLUM0198-005".
460
+ const idRe = /^\s*([A-Z]{2,5}\d{3,4}(?:-\d{2,3})?)\b/;
461
+
462
+ let buffer: string[] = [];
463
+ let currentId: string | null = null;
464
+ for (const raw of lines) {
465
+ const line = raw ?? "";
466
+ const idm = idRe.exec(line);
467
+ if (idm && idm[1] && !/\$/.test(line)) {
468
+ currentId = idm[1];
469
+ // An identifier header is not part of a craft label.
470
+ continue;
471
+ }
472
+ const m = rateLineRe.exec(line);
473
+ if (m && m[1] !== undefined && m[2] !== undefined) {
474
+ // Closing line of a (possibly wrapped) craft block.
475
+ const tail = m[1];
476
+ const fullLabel = [...buffer, tail]
477
+ .join(" ")
478
+ .replace(/\.{2,}/g, " ") // collapse dot leaders
479
+ .replace(/\s+/g, " ")
480
+ // Strip the "Rates Fringes" column header if it bled into the label
481
+ // (it can precede the first craft with no blank line between).
482
+ .replace(/^\s*Rates\s+Fringes\s*/i, "")
483
+ .replace(/\s*\)\s*$/, ")")
484
+ .trim();
485
+ buffer = [];
486
+ if (fullLabel && /[A-Za-z]/.test(fullLabel)) {
487
+ // Short craft class = text before the first ':' or '(' (the scope).
488
+ const craft = fullLabel.split(/[:(]/)[0]?.trim() || fullLabel;
489
+ rates.push({
490
+ craft,
491
+ title: fullLabel,
492
+ baseRate: Number(m[2]),
493
+ fringePerHour: m[3] !== undefined ? Number(m[3]) : null,
494
+ rateIdentifier: currentId,
495
+ });
496
+ }
497
+ continue;
498
+ }
499
+ // A non-rate, non-id content line → part of a wrapping craft label. Ignore
500
+ // pure separators / page furniture (dashes, blank lines) and the column
501
+ // header row.
502
+ const trimmed = line.trim();
503
+ if (/^Rates\s+Fringes$/i.test(trimmed)) {
504
+ buffer = [];
505
+ } else if (trimmed && !/^[-_=]{3,}$/.test(trimmed) && /[A-Za-z]/.test(trimmed)) {
506
+ buffer.push(trimmed);
507
+ } else {
508
+ // Blank or separator resets an in-progress wrap so stray text never
509
+ // bleeds into the next craft.
510
+ buffer = [];
511
+ }
512
+ }
513
+ const eo = parseExecutiveOrderMinimum(doc);
514
+ const parseConfidence: "high" | "low" = rates.length >= 3 ? "high" : "low";
515
+ return { rates, executiveOrderMinimum: eo, parseConfidence };
516
+ }
517
+
518
+ /**
519
+ * Read the EO minimum-wage floor from the WD text. The document cites EO 13658
520
+ * OR EO 14026 with a "at least $XX.XX per hour" figure — we read whichever the
521
+ * text carries (both the EO number and the dollar figure) and NEVER hardcode.
522
+ */
523
+ function parseExecutiveOrderMinimum(
524
+ doc: string,
525
+ ): { executiveOrder: string; minimumWage: number } | null {
526
+ // Look for "$XX.XX per hour" appearing near an "Executive Order NNNNN" cite.
527
+ // The canonical phrasing is "... must pay all covered workers at least
528
+ // $13.65 per hour ...". Prefer a line/paragraph that also names the EO.
529
+ const eoNumMatch = /Executive Order\s+(\d{5})/i.exec(doc);
530
+ const wageMatch = /at least\s+\$\s*(\d{1,2}\.\d{2})\s+per hour/i.exec(doc);
531
+ if (wageMatch && wageMatch[1]) {
532
+ return {
533
+ executiveOrder: eoNumMatch && eoNumMatch[1] ? `EO ${eoNumMatch[1]}` : "unknown",
534
+ minimumWage: Number(wageMatch[1]),
535
+ };
536
+ }
537
+ return null;
538
+ }
539
+
540
+ export async function getWageRates(args: {
541
+ reference: string;
542
+ revision?: number;
543
+ coverage?: string;
544
+ format?: "parsed" | "raw" | "both";
545
+ }) {
546
+ const format = args.format ?? "parsed";
547
+ const reference = args.reference.trim();
548
+
549
+ // Resolve the revision: use the caller's, else the latest ACTIVE via /history.
550
+ let revision = args.revision;
551
+ let revisionResolvedFromHistory = false;
552
+ if (revision === undefined || revision === null) {
553
+ const latest = await latestActiveRevision(reference);
554
+ if (latest === null) {
555
+ throw new ToolErrorCarrier({
556
+ kind: "not_found",
557
+ message: `No wage-determination history found for reference '${reference}' on sam.gov/wdol. Verify the fullReferenceNumber via sam_search_wage_determinations.`,
558
+ retryable: false,
559
+ upstreamEndpoint: `wdol/v1/wd/${reference}/history`,
560
+ });
561
+ }
562
+ revision = latest;
563
+ revisionResolvedFromHistory = true;
564
+ }
565
+
566
+ const url = `${WDOL_BASE}/${encodeURIComponent(reference)}/${encodeURIComponent(String(revision))}`;
567
+ // A genuinely-absent WD → structured not_found; a 5xx/429 stays retryable
568
+ // (fetchWithRetry throws a classified ToolErrorCarrier — do not swallow).
569
+ let detail: WdDetailResp;
570
+ try {
571
+ detail = await getJson<WdDetailResp>(url, SAM_HAL_HEADERS, "sam:wdol:detail");
572
+ } catch (e) {
573
+ if (e instanceof ToolErrorCarrier && e.toolError.kind === "not_found") {
574
+ throw new ToolErrorCarrier({
575
+ kind: "not_found",
576
+ message: `No wage determination found at ${reference}/${revision} on sam.gov/wdol. Resolve a valid reference+revision via sam_search_wage_determinations.`,
577
+ retryable: false,
578
+ upstreamEndpoint: `wdol/v1/wd/${reference}/${revision}`,
579
+ });
580
+ }
581
+ throw e;
582
+ }
583
+
584
+ const doc = detail.document ?? "";
585
+ if (!doc) {
586
+ throw new ToolErrorCarrier({
587
+ kind: "schema_drift",
588
+ message: `Wage determination ${reference}/${revision} returned no document text — cannot read rates.`,
589
+ retryable: false,
590
+ upstreamEndpoint: `wdol/v1/wd/${reference}/${revision}`,
591
+ });
592
+ }
593
+
594
+ // Decide SCA vs DBA. Prefer the caller's hint; else infer from the reference
595
+ // shape / document content (DBA refs are like "IA20260028" / craft columns).
596
+ const coverageHint = args.coverage ? coverageToIndex(args.coverage) : null;
597
+ const looksDba =
598
+ coverageHint === "dbra" ||
599
+ (coverageHint === null &&
600
+ (/[A-Z]{2}\d{6,}/.test(reference) || /\bFringes\b/.test(doc)) &&
601
+ !/^\d{4}-\d{4}$/.test(reference));
602
+ const coverage: "SCA" | "DBA" = looksDba ? "DBA" : "SCA";
603
+
604
+ const sourceUrl = `https://sam.gov/wage-determination/${reference}/${revision}`;
605
+ const notes: string[] = [];
606
+ const fieldsUnavailable: string[] = [];
607
+
608
+ let parsed:
609
+ | ReturnType<typeof parseScaDocument>
610
+ | ReturnType<typeof parseDbaDocument>
611
+ | null = null;
612
+ let parseConfidence: "high" | "low" = "low";
613
+ const data: Record<string, unknown> = {
614
+ reference,
615
+ revision,
616
+ coverage,
617
+ active: detail.active ?? null,
618
+ standard: detail.standard ?? null,
619
+ publishDate: detail.publishDate ?? null,
620
+ sourceUrl,
621
+ locationMapping: (detail.location?.mapping ?? []).map((m) => ({
622
+ state: m.state ?? null,
623
+ counties: m.counties ?? [],
624
+ statewide: m.statewideFlag ?? false,
625
+ })),
626
+ };
627
+
628
+ if (format !== "raw") {
629
+ if (coverage === "SCA") {
630
+ const p = parseScaDocument(doc);
631
+ parsed = p;
632
+ parseConfidence = p.parseConfidence;
633
+ data.rates = p.rates;
634
+ data.healthAndWelfarePerHour = p.healthAndWelfarePerHour;
635
+ data.executiveOrderMinimumWage = p.executiveOrderMinimum;
636
+ if (p.healthAndWelfarePerHour === null) {
637
+ fieldsUnavailable.push("healthAndWelfarePerHour");
638
+ }
639
+ notes.push(
640
+ "SCA wage determination: `healthAndWelfarePerHour` is a WD-WIDE Health & Welfare rate that applies to ALL listed occupations (it is NOT per-occupation). Each rate row's `baseRate` is the hourly minimum for that occupation code.",
641
+ );
642
+ if (p.rates.some((r) => r.footnotes && r.footnotes.length)) {
643
+ notes.push(
644
+ "Some occupations carry `footnotes` (the WD's numbered '(see N)' markers) — these signal MATERIAL extra pay rules (e.g. night/Sunday differential, FLSA exemption) beyond the listed baseRate. The footnote NUMBERS are surfaced per rate row; call again with format:'raw' (or 'both') to read the exact footnote text.",
645
+ );
646
+ }
647
+ } else {
648
+ const p = parseDbaDocument(doc);
649
+ parsed = p;
650
+ parseConfidence = p.parseConfidence;
651
+ data.rates = p.rates;
652
+ data.executiveOrderMinimumWage = p.executiveOrderMinimum;
653
+ notes.push(
654
+ "DBA (Davis-Bacon) wage determination: each rate row carries its OWN `fringePerHour` (fringe is PER-CRAFT, not a single WD-wide figure). Craft labels are parsed from a fixed-width text form and may include wrapped scope text; set format:'raw' to read the exact document.",
655
+ );
656
+ }
657
+ data.parseConfidence = parseConfidence;
658
+ if (parsed && parsed.executiveOrderMinimum === null) {
659
+ fieldsUnavailable.push("executiveOrderMinimumWage");
660
+ notes.push(
661
+ "No Executive Order minimum-wage figure could be parsed from this WD's text.",
662
+ );
663
+ } else if (parsed && parsed.executiveOrderMinimum) {
664
+ notes.push(
665
+ `Executive-order minimum-wage FLOOR parsed from the WD text: ${parsed.executiveOrderMinimum.executiveOrder} at $${parsed.executiveOrderMinimum.minimumWage.toFixed(2)}/hour. This floor is read from whichever EO the document cites (13658 vs 14026) and applies on top of any lower listed base rate.`,
666
+ );
667
+ }
668
+ if (parseConfidence === "low") {
669
+ notes.push(
670
+ "parseConfidence is LOW — the fixed-width layout did not match cleanly. Prefer the raw `document` text (call again with format:'raw' or 'both') rather than trusting the parsed rows.",
671
+ );
672
+ }
673
+ }
674
+
675
+ if (format === "raw" || format === "both") {
676
+ data.document = doc;
677
+ }
678
+ if (format === "raw") {
679
+ // Raw mode: we deliberately do not assert parsed structure.
680
+ data.parseConfidence = "low";
681
+ notes.push(
682
+ "format:'raw' — the full wage-determination text is returned in `document`; no rate parsing was applied.",
683
+ );
684
+ }
685
+
686
+ notes.push(
687
+ "Wage rates are PARSED best-effort from the WD's plain-text document (SAM does not expose them as structured JSON)." +
688
+ (revisionResolvedFromHistory
689
+ ? ` Revision ${revision} was resolved as the latest ACTIVE revision via /history (you did not pass one).`
690
+ : ""),
691
+ );
692
+
693
+ return withMeta(data, {
694
+ source: WD_DETAIL_SOURCE,
695
+ keylessMode: true,
696
+ returned: Array.isArray(data.rates) ? (data.rates as unknown[]).length : 1,
697
+ totalAvailable: Array.isArray(data.rates) ? (data.rates as unknown[]).length : 1,
698
+ truncated: false,
699
+ filtersApplied: [`coverage(${coverage})`, `revision(${revision})`],
700
+ filtersDropped: [],
701
+ fieldsUnavailable,
702
+ notes,
703
+ });
704
+ }
705
+
706
+ // ─── 3. gsa_benchmark_labor_rates ────────────────────────────────
707
+
708
+ const CALC_SOURCE = "api.gsa.gov CALC v3 ceilingrates (keyless)";
709
+
710
+ type CalcSource = {
711
+ labor_category?: string;
712
+ current_price?: number;
713
+ next_year_price?: number;
714
+ second_year_price?: number;
715
+ education_level?: string;
716
+ min_years_experience?: number;
717
+ business_size?: string;
718
+ security_clearance?: string | boolean;
719
+ worksite?: string;
720
+ sin?: string;
721
+ schedule?: string;
722
+ vendor_name?: string;
723
+ idv_piid?: string;
724
+ contract_start?: string;
725
+ contract_end?: string;
726
+ };
727
+
728
+ type CalcResp = {
729
+ hits?: {
730
+ total?: { value?: number; relation?: string };
731
+ hits?: { _source?: CalcSource }[];
732
+ };
733
+ };
734
+
735
+ // Filters LIVE-VERIFIED to narrow via `filter=field:value` (2026-07-03):
736
+ // business_size, education_level (uses SHORT CODES like BA/MA/AA/HS/PHD — the
737
+ // returned field shows full words too, so codes≠displayed), min_years_experience,
738
+ // experience_range, sin. price_range / security_clearance / worksite did NOT
739
+ // narrow via filter and are treated as non-filterable here (price_range re-
740
+ // confirmed a no-op 2026-07-05 and again 2026-07-12 — see F1 in the handler).
741
+ function median(sorted: number[]): number | null {
742
+ if (sorted.length === 0) return null;
743
+ const mid = Math.floor(sorted.length / 2);
744
+ const hi = sorted[mid] ?? null;
745
+ if (hi === null) return null;
746
+ if (sorted.length % 2 === 0) {
747
+ const lo = sorted[mid - 1] ?? hi;
748
+ return (lo + hi) / 2;
749
+ }
750
+ return hi;
751
+ }
752
+
753
+ /**
754
+ * Shape-drift guard for the CALC v3 envelope. A valid 200 MUST carry
755
+ * `hits:{ total:{ value:<number> }, hits:[…] }`. A drifted shape — `hits`
756
+ * null/absent/non-object, `hits.hits` not an array, or `hits.total.value` not a
757
+ * number — would otherwise be SILENTLY mapped to an empty/degraded distribution
758
+ * (a fabricated-empty). So we THROW schema_drift instead (mirrors the getWageRates
759
+ * empty-document discipline in this file). A GENUINE zero-results response — a
760
+ * well-formed envelope with `hits.hits:[]` and `total.value:0` — PASSES the guard
761
+ * and stays an honest empty; ONLY a shape drift throws.
762
+ */
763
+ function assertCalcEnvelope(resp: CalcResp, label: string): void {
764
+ const hits = (resp as { hits?: unknown }).hits;
765
+ if (hits === null || typeof hits !== "object" || Array.isArray(hits)) {
766
+ throw new ToolErrorCarrier({
767
+ kind: "schema_drift",
768
+ message: `GSA CALC v3 (${label}) returned an HTTP 200 without a hits{} object — the ceilingrates envelope changed. Refusing to fabricate an empty rate distribution.`,
769
+ retryable: false,
770
+ upstreamEndpoint: CALC_BASE,
771
+ });
772
+ }
773
+ const h = hits as { hits?: unknown; total?: unknown };
774
+ if (!Array.isArray(h.hits)) {
775
+ throw new ToolErrorCarrier({
776
+ kind: "schema_drift",
777
+ message: `GSA CALC v3 (${label}) returned an HTTP 200 whose hits.hits is not an array — the ceilingrates envelope changed. Refusing to fabricate an empty rate distribution.`,
778
+ retryable: false,
779
+ upstreamEndpoint: CALC_BASE,
780
+ });
781
+ }
782
+ const total = h.total as { value?: unknown } | null | undefined;
783
+ if (total === null || typeof total !== "object" || typeof total.value !== "number") {
784
+ throw new ToolErrorCarrier({
785
+ kind: "schema_drift",
786
+ message: `GSA CALC v3 (${label}) returned an HTTP 200 without a numeric hits.total.value — the ceilingrates total envelope changed. Refusing to fabricate a match count.`,
787
+ retryable: false,
788
+ upstreamEndpoint: CALC_BASE,
789
+ });
790
+ }
791
+ }
792
+
793
+ export async function benchmarkLaborRates(args: {
794
+ laborCategory: string;
795
+ businessSize?: string;
796
+ educationLevel?: string;
797
+ minYearsExperience?: number;
798
+ experienceRange?: string;
799
+ sin?: string;
800
+ priceRange?: string;
801
+ maxSamplePages?: number;
802
+ }) {
803
+ const laborCategory = args.laborCategory.trim();
804
+ // Page in bounded chunks to build a representative distribution sample
805
+ // without unbounded fetching. CALC honors page_size up to at least 200
806
+ // (LIVE-VERIFIED), so 100 rows/page captures most categories' full
807
+ // population within the default page budget.
808
+ const PAGE_ROWS = 100;
809
+ const maxSamplePages = Math.min(10, Math.max(1, Math.floor(args.maxSamplePages ?? 3)));
810
+
811
+ const filters: string[] = [];
812
+ const filtersApplied: string[] = ["laborCategory(exact)"];
813
+ const filtersDropped: string[] = [];
814
+ if (args.businessSize) {
815
+ filters.push(`business_size:${args.businessSize}`);
816
+ filtersApplied.push(`businessSize`);
817
+ }
818
+ if (args.educationLevel) {
819
+ filters.push(`education_level:${args.educationLevel}`);
820
+ filtersApplied.push(`educationLevel`);
821
+ }
822
+ if (args.minYearsExperience !== undefined) {
823
+ filters.push(`min_years_experience:${args.minYearsExperience}`);
824
+ filtersApplied.push(`minYearsExperience`);
825
+ }
826
+ if (args.experienceRange) {
827
+ filters.push(`experience_range:${args.experienceRange}`);
828
+ filtersApplied.push(`experienceRange`);
829
+ }
830
+ if (args.sin) {
831
+ filters.push(`sin:${args.sin}`);
832
+ filtersApplied.push(`sin`);
833
+ }
834
+ if (args.priceRange) {
835
+ // F1 (P4 no-silent-filter, HIGH — money domain): CALC v3 IGNORES the
836
+ // price_range filter — it does NOT narrow the result set (LIVE-RE-CONFIRMED
837
+ // 2026-07-12: `search=labor_category:Paralegal` returns total {value:10,
838
+ // relation:"eq"} with the SAME rows whether price_range is absent, 1000-2000,
839
+ // or 200-300). Claiming it in filtersApplied — and advising an agent to use
840
+ // it to narrow — would be a money-domain lie (the distribution below is over
841
+ // the UN-narrowed set). So we do NOT send the no-op param, and disclose it as
842
+ // DROPPED (never applied) with a best-effort note (see the notes block).
843
+ filtersDropped.push("priceRange");
844
+ }
845
+
846
+ function buildUrl(matcher: "search" | "q", page: number): string {
847
+ const p = new URLSearchParams();
848
+ if (matcher === "search") p.set("search", `labor_category:${laborCategory}`);
849
+ else p.set("q", laborCategory);
850
+ p.set("page", String(page));
851
+ p.set("page_size", String(PAGE_ROWS));
852
+ p.set("ordering", "current_price");
853
+ let qs = p.toString();
854
+ for (const f of filters) qs += `&filter=${encodeURIComponent(f)}`;
855
+ return `${CALC_BASE}?${qs}`;
856
+ }
857
+
858
+ // Primary: exact field match. Fall back to loose `q` ONLY if exact returns 0.
859
+ let matcher: "search" | "q" = "search";
860
+ let first = await getJson<CalcResp>(buildUrl("search", 1), { Accept: "application/json" }, "gsa:calc:search");
861
+ // Shape-drift guard BEFORE reading the total (a drifted `hits:null` would read as
862
+ // totalValue 0, silently trigger the `q` fallback, and end as a fabricated-empty).
863
+ assertCalcEnvelope(first, "search");
864
+ let totalValue = first.hits?.total?.value ?? 0;
865
+ let totalRelation = first.hits?.total?.relation ?? "eq";
866
+ let fuzzy = false;
867
+ if (totalValue === 0) {
868
+ const q = await getJson<CalcResp>(buildUrl("q", 1), { Accept: "application/json" }, "gsa:calc:q");
869
+ assertCalcEnvelope(q, "q");
870
+ if ((q.hits?.total?.value ?? 0) > 0) {
871
+ matcher = "q";
872
+ fuzzy = true;
873
+ first = q;
874
+ totalValue = q.hits?.total?.value ?? 0;
875
+ totalRelation = q.hits?.total?.relation ?? "eq";
876
+ }
877
+ }
878
+
879
+ // ── Build the current-price distribution ────────────────────────────────
880
+ // CALC serves a GLOBALLY ascending-by-current_price list and reports an exact
881
+ // `total` for non-saturated queries (descending sort → HTTP 406; the
882
+ // `price_range` filter does NOT narrow — both LIVE-VERIFIED 2026-07-05). So the
883
+ // first N rows are the CHEAPEST N: sampling only those and reporting
884
+ // min/median/max understates the distribution badly (a 2000+-row category
885
+ // understated its median by ~35% and its max by ~70% in practice). When the
886
+ // total is KNOWN we therefore read the EXACT min/median/max by paging straight
887
+ // to the quantile RANKS, touching only a few targeted pages. Only when the
888
+ // count is SATURATED (true total unknown) do we fall back to a leading-rows
889
+ // sample — and then we DISCLOSE that median/max are a downward-biased lower
890
+ // bound, never presenting them as the true distribution.
891
+ const saturated = totalRelation !== "eq"; // e.g. "gte" at the 10000 cap
892
+ const matchCount = totalValue;
893
+
894
+ const firstRows = (first.hits?.hits ?? []).map((h) => h._source ?? {});
895
+ const pageCache = new Map<number, CalcSource[]>([[1, firstRows]]);
896
+ async function rowsForPage(pg: number): Promise<CalcSource[]> {
897
+ const cached = pageCache.get(pg);
898
+ if (cached) return cached;
899
+ const r = await getJson<CalcResp>(
900
+ buildUrl(matcher, pg),
901
+ { Accept: "application/json" },
902
+ "gsa:calc:page",
903
+ );
904
+ assertCalcEnvelope(r, `page ${pg}`);
905
+ const pr = (r.hits?.hits ?? []).map((h) => h._source ?? {});
906
+ pageCache.set(pg, pr);
907
+ return pr;
908
+ }
909
+ // Exact current_price at a 0-indexed GLOBAL rank in the ascending list. Sets
910
+ // `rankClamped` if the requested offset isn't present (CALC limited deep
911
+ // paging, or the total exceeded the paginable rows) so we DOWNGRADE from
912
+ // "exact" rather than silently report a clamped (low) value as the truth.
913
+ let rankClamped = false;
914
+ async function priceAtRank(rank: number): Promise<number | null> {
915
+ const pg = Math.floor(rank / PAGE_ROWS) + 1;
916
+ const off = rank % PAGE_ROWS;
917
+ const pr = await rowsForPage(pg);
918
+ let row = pr[off];
919
+ if (row === undefined) {
920
+ rankClamped = true;
921
+ row = pr[pr.length - 1];
922
+ }
923
+ const v = row?.current_price;
924
+ return typeof v === "number" ? v : null;
925
+ }
926
+
927
+ let currentRateStat: {
928
+ min: number | null;
929
+ median: number | null;
930
+ max: number | null;
931
+ n: number;
932
+ };
933
+ let statsExact: boolean;
934
+ let rows: CalcSource[]; // rows backing the SOFT stats (escalation/education/samples)
935
+
936
+ if (!saturated && matchCount > 0) {
937
+ // EXACT path: read the true min/median/max by rank. For an even population
938
+ // the median is the mean of the two central ranks.
939
+ const minV = await priceAtRank(0);
940
+ const maxV = await priceAtRank(matchCount - 1);
941
+ let medianV: number | null;
942
+ if (matchCount % 2 === 0) {
943
+ const a = await priceAtRank(matchCount / 2 - 1);
944
+ const b = await priceAtRank(matchCount / 2);
945
+ medianV = a !== null && b !== null ? (a + b) / 2 : (a ?? b);
946
+ } else {
947
+ medianV = await priceAtRank((matchCount - 1) / 2);
948
+ }
949
+ // If a rank couldn't be reached (paging cap) or a price was missing, the
950
+ // read is no longer provably exact — degrade honestly to the fallback story.
951
+ statsExact =
952
+ !rankClamped && minV !== null && medianV !== null && maxV !== null;
953
+ currentRateStat = { min: minV, median: medianV, max: maxV, n: matchCount };
954
+ // Soft stats run over the STRATIFIED union of the quantile pages we fetched
955
+ // (low/median/high bands) — far more representative than the first N
956
+ // cheapest rows, though not exhaustive.
957
+ rows = [...pageCache.values()].flat();
958
+ } else {
959
+ // FALLBACK (saturated / unknown total): we cannot locate the quantile ranks,
960
+ // so sample the leading (cheapest) pages. min stays exact; median/max are a
961
+ // DISCLOSED downward-biased lower bound over the lowest-priced rows.
962
+ for (let page = 2; page <= maxSamplePages; page++) {
963
+ const pr = await rowsForPage(page);
964
+ if (pr.length === 0) break;
965
+ if (pr.length < PAGE_ROWS) break;
966
+ }
967
+ rows = [...pageCache.values()].flat();
968
+ const cur = rows
969
+ .map((r) => r.current_price)
970
+ .filter((n): n is number => typeof n === "number")
971
+ .sort((a, b) => a - b);
972
+ currentRateStat = {
973
+ min: cur[0] ?? null,
974
+ median: median(cur),
975
+ max: cur[cur.length - 1] ?? null,
976
+ n: cur.length,
977
+ };
978
+ statsExact = false;
979
+ }
980
+
981
+ const nextYear = rows.map((r) => r.next_year_price).filter((n): n is number => typeof n === "number").sort((a, b) => a - b);
982
+ const secondYear = rows.map((r) => r.second_year_price).filter((n): n is number => typeof n === "number").sort((a, b) => a - b);
983
+
984
+ // The distinct education_level values actually PRESENT in the fetched rows (the
985
+ // vocabulary is inconsistent — a mix of codes and full words), so the AI sees
986
+ // the real values rather than a fabricated canonical set.
987
+ const educationLevelsInSample = [
988
+ ...new Set(rows.map((r) => r.education_level).filter((x): x is string => Boolean(x))),
989
+ ];
990
+
991
+ const data = {
992
+ laborCategory,
993
+ matcher, // "search" (exact) | "q" (fuzzy fallback)
994
+ fuzzy,
995
+ matchCount,
996
+ matchCountSaturated: saturated,
997
+ filtersApplied: filters,
998
+ sampleSize: rows.length,
999
+ currentRate: currentRateStat,
1000
+ // Whether currentRate {min,median,max} are EXACT over all matches (read at
1001
+ // the quantile ranks) or a downward-biased lower bound over a leading sample
1002
+ // (saturated/unknown total). An AI must NOT read a biased median as the
1003
+ // market middle — this flag + the notes say which it is.
1004
+ currentRateExact: statsExact,
1005
+ escalatedRate: {
1006
+ nextYearMedian: median(nextYear),
1007
+ secondYearMedian: median(secondYear),
1008
+ },
1009
+ educationLevelsInSample,
1010
+ sampleRows: rows.slice(0, 10).map((r) => ({
1011
+ laborCategory: r.labor_category ?? null,
1012
+ currentRate: r.current_price ?? null,
1013
+ nextYearRate: r.next_year_price ?? null,
1014
+ secondYearRate: r.second_year_price ?? null,
1015
+ educationLevel: r.education_level ?? null,
1016
+ minYearsExperience: r.min_years_experience ?? null,
1017
+ businessSize: r.business_size ?? null,
1018
+ securityClearance: r.security_clearance ?? null,
1019
+ worksite: r.worksite ?? null,
1020
+ sin: r.sin ?? null,
1021
+ schedule: r.schedule ?? null,
1022
+ vendor: r.vendor_name ?? null,
1023
+ idvPiid: r.idv_piid ?? null,
1024
+ })),
1025
+ };
1026
+
1027
+ const notes: string[] = [
1028
+ "CALC rates are awarded CEILING (catalog) rates from GSA schedule contracts — they are NOT actual task-order prices paid, and real competed prices are frequently lower.",
1029
+ "CALC rates are FULLY BURDENED (they already include the contractor's wrap: overhead, G&A, fringe, and fee) — do NOT re-apply a wrap rate on top.",
1030
+ "The escalatedRate medians (nextYear/secondYear) are each vendor's own contracted escalation, NOT a market escalation index — treat them as a distribution, not a forecast.",
1031
+ statsExact
1032
+ ? `currentRate {min, median, max} are EXACT over all ${matchCount} matches — read directly at the quantile ranks of CALC's ascending price-sorted index, NOT a leading subsample. (min/median/max come from separate paged requests, so under an active CALC index refresh they could reflect slightly different snapshots.) escalatedRate medians and educationLevelsInSample are computed over a ${rows.length}-row sample covering the low, median, and high price points, so treat those two as representative estimates rather than exhaustive.`
1033
+ : `currentRate.min is exact, but currentRate.median/max are computed over the ${rows.length} LOWEST-priced sampled row(s) and are a DOWNWARD-BIASED LOWER BOUND: CALC returns rows in ascending price order and the exact total was not known (saturated), so the true median/max are HIGHER than reported. Narrow with filters (businessSize, educationLevel code, minYearsExperience, sin) or a more specific laborCategory to get an exact count and unbiased statistics.`,
1034
+ ];
1035
+ if (args.priceRange) {
1036
+ // F1: disclose that priceRange was NOT applied (CALC v3 ignores it), so the
1037
+ // agent never treats the distribution as narrowed to that price band.
1038
+ notes.push(
1039
+ "priceRange was NOT applied and is reported in _meta.filtersDropped: GSA CALC v3 IGNORES the price_range filter (it does not narrow the result set — live-verified), so the counts/distribution above are over the UN-narrowed set. To narrow, use businessSize / educationLevel (code) / minYearsExperience / sin, or a more specific laborCategory, instead.",
1040
+ );
1041
+ }
1042
+ if (saturated) {
1043
+ notes.push(
1044
+ `matchCount is SATURATED: the API returned relation='${totalRelation}' at ${matchCount}, so the true match total is AT LEAST ${matchCount} (unknown exact). totalAvailable is null. Narrow with filters (businessSize, educationLevel code, minYearsExperience, sin) or a more specific laborCategory for an exact count.`,
1045
+ );
1046
+ }
1047
+ if (fuzzy) {
1048
+ notes.push(
1049
+ `No exact labor_category match for '${laborCategory}' — FELL BACK to a loose keyword (q) match, which is broad and low-precision (q matches many unrelated categories). Treat these results as FUZZY; refine the laborCategory to a canonical CALC label for a precise band.`,
1050
+ );
1051
+ }
1052
+ if (args.educationLevel) {
1053
+ notes.push(
1054
+ "CALC's education_level FILTER expects short codes (e.g. BA, MA, AA, HS, PHD) — the returned education_level FIELD may show full words ('Bachelors','Masters','High School') for the same rows, so filter values differ from displayed values. If the filter did not narrow, try the short-code form. See educationLevelsInSample for the actual returned vocabulary.",
1055
+ );
1056
+ }
1057
+
1058
+ return withMeta(data, {
1059
+ source: CALC_SOURCE,
1060
+ keylessMode: true,
1061
+ returned: rows.length,
1062
+ // totalAvailable is REAL only when relation === "eq"; saturated → null.
1063
+ totalAvailable: saturated ? null : matchCount,
1064
+ // `truncated` keeps its codebase-wide meaning: fewer rows were RETURNED than
1065
+ // exist (we page only to the quantile ranks, not the whole set). Distribution
1066
+ // completeness is a SEPARATE signal — `currentRateExact` above — so a consumer
1067
+ // is never told "complete data" when only a sample of rows came back, yet also
1068
+ // learns the min/median/max are exact.
1069
+ truncated: rows.length < matchCount,
1070
+ filtersApplied,
1071
+ filtersDropped,
1072
+ fieldsUnavailable: [],
1073
+ notes,
1074
+ });
1075
+ }