# AI Grounding and Verification

> How /v1/ai gets its numbers from deterministic currency tools, why data is canonical, how to verify convert and history results against REST, and what is not auditable yet.

Canonical: https://currency-core.com/docs/ai-grounding/

---
`/v1/ai` is grounded in tools, not in the model's memory. The model reads your
question, chooses tool calls, and writes prose from the results. The numbers come
from CurrencyCore's deterministic engine. The prose can still be wrong.

The practical rule: **treat `data` as canonical and treat `answer` as a summary
that a human should read, not as a source of record.**

## How grounding works

1. Your question is classified and sent to the language model with a fixed set
   of currency tools.
2. The model chooses a tool and arguments. Examples: a conversion with an amount
   and date, a multi-currency rate list, a history window, a PPP country pair.
3. CurrencyCore runs the tool against the same rate, history, and PPP data that
   powers the REST endpoints. The result is a structured object.
4. The model sees a compacted copy of that result and writes a plain-text
   `answer`.
5. The full structured result is returned in `data`, without the internal
   currency-resolution step.

This is why the response usually contains a number that matches REST. The prompt
requires a tool for real numbers, so an invented rate is unlikely, but this does
not make the prose infallible.

The deterministic tools cover:

- Conversion and single-pair rates.
- Multi-currency rate lists.
- Historical trend, comparison, movers, and report analysis.
- PPP factor analysis over time.
- Volatility and stability.
- Correlation, drawdown, safe haven, mean reversion, risk-adjusted return, and
  strengthening streaks.

Each tool returns a structured object. The model never receives a database
handle; it receives the result object, and the same object (minus the internal
currency-resolution step) is what appears in `data`.

## Data is canonical, prose is not

| Risk | What can happen | How to handle it |
|---|---|---|
| Rounding | The answer says "about 5,255" while `result` is `5255.4`. | Use `data[].result` and `data[].rate` for calculations. |
| Direction wording | The model says a currency "rose" when it weakened, especially across `changePct` and `strengthenedPct`. | Use `strengthenedPct` from `data`; positive means stronger against the base. |
| Unit wording | The model expresses a rate in the inverse direction from what you expected. | Rates are target units per one source unit. Check `rate` and `amount`. |
| Date wording | The answer says "today" while `asOf` is the last trading day. | Show `asOf`, not the request time. |
| Selection | The answer names a currency or country that is not in `data`. | Render from `data`; do not parse names out of prose. |
| Truncation | A long report stops mid-thought. | The server trims to a sentence boundary, but a report can still be incomplete. Use REST for full detail. |
| Model choice | The same question can produce different prose on different runs or providers. | Do not snapshot `answer` text in tests. Assert on `data`. |

The model runs at a low temperature, not at zero. The numbers are deterministic
when the inputs are; the wording is not.

## Verify a conversion

A convert datum carries everything you need to re-run the calculation:

```json
{
  "from": "EUR",
  "to": "INR",
  "amount": 50,
  "result": 5255.4,
  "rate": 105.108,
  "pppApplied": false,
  "asOf": "2026-11-10"
}
```

Query [/v1/convert](/docs/convert-endpoint/) with the same `from`, `to`,
`amount`, and `date: asOf`:

```
GET /v1/convert?from=EUR&to=INR&amount=50&date=2026-11-10
```

Compare `results[].result` and `results[].rate`. They should match within
rounding. A small difference can come from a different rounding step, but a large
difference means you should not trust the AI response for that request.

The `./ai-types` import below refers to the types on
[AI response schema](/docs/ai-response-schema/). Copy them into a local module;
there is no published client type package for `/v1/ai`.

```ts

const CLOSE_ENOUGH = 0.0001;

function close(a: number, b: number, relativeTolerance = CLOSE_ENOUGH): boolean {
  if (!Number.isFinite(a) || !Number.isFinite(b)) return false;
  const scale = Math.max(Math.abs(a), Math.abs(b), 1);
  return Math.abs(a - b) <= scale * relativeTolerance;
}

export async function verifyConvert(
  datum: AiConvertDatum,
  key: string,
): Promise<{ ok: boolean; restResult?: number; restRate?: number; reason?: string }> {
  if (datum.pppApplied) {
    return {
      ok: false,
      reason: "PPP conversions need the country-aware /v1/convert?ppp=true form.",
    };
  }

  const url = new URL("https://api.currency-core.com/v1/convert");
  url.searchParams.set("from", datum.from);
  url.searchParams.set("to", datum.to);
  url.searchParams.set("amount", String(datum.amount));
  url.searchParams.set("date", datum.asOf);

  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${key}` },
    signal: AbortSignal.timeout(20_000),
  });
  if (!res.ok) return { ok: false, reason: `HTTP ${res.status}` };

  const body = await res.json();
  const row = body.results?.find((r: { to: string }) => r.to === datum.to);
  if (!row) return { ok: false, reason: `No ${datum.to} row in /v1/convert` };

  const ok = close(row.result, datum.result) && close(row.rate, datum.rate);
  return {
    ok,
    restResult: row.result,
    restRate: row.rate,
    ...(ok ? {} : { reason: "Result or rate differs beyond tolerance." }),
  };
}
```

For a PPP conversion, verify against
[/v1/convert?ppp=true](/docs/convert-endpoint/#ppp-country-auto-resolution) with
the same `currency:country` pairs. Remember that the AI convert datum's `rate` is
the PPP ratio (`result / amount`), not the market FX rate.

## Verify a history analysis

A history-analysis datum carries `base`, `from`, `to`, and one row per currency
with `changePct` and `strengthenedPct`. Re-run
[/v1/history/analysis](/docs/history-analysis-endpoint/) with the same `base`,
window, and currency list.

```ts

function samePercent(a: number | null, b: number | null, tolerance = 0.5): boolean {
  if (a == null || b == null) return a === b;
  return Math.abs(a - b) <= tolerance;
}

export async function verifyHistory(
  datum: AiHistoryAnalysisDatum,
  key: string,
): Promise<{ ok: boolean; mismatches: string[] }> {
  const url = new URL("https://api.currency-core.com/v1/history/analysis");
  url.searchParams.set("base", datum.base);
  url.searchParams.set("from", datum.from);
  url.searchParams.set("to", datum.to);
  url.searchParams.set("currencies", datum.results.map((r) => r.currency).join(","));

  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${key}` },
    signal: AbortSignal.timeout(20_000),
  });
  if (!res.ok) return { ok: false, mismatches: [`HTTP ${res.status}`] };

  const body = await res.json();
  const restByCode = new Map<string, { changePct: number | null; strengthenedPct: number | null }>(
    (body.results ?? []).map((r: { currency: string; changePct: number | null; strengthenedPct: number | null }) => [
      r.currency,
      r,
    ]),
  );

  const mismatches: string[] = [];
  for (const row of datum.results) {
    const rest = restByCode.get(row.currency);
    if (!rest) {
      mismatches.push(`${row.currency}: missing from /v1/history/analysis`);
      continue;
    }
    if (!samePercent(row.changePct, rest.changePct)) {
      mismatches.push(`${row.currency}: changePct ${row.changePct} vs ${rest.changePct}`);
    }
    if (!samePercent(row.strengthenedPct, rest.strengthenedPct)) {
      mismatches.push(
        `${row.currency}: strengthenedPct ${row.strengthenedPct} vs ${rest.strengthenedPct}`,
      );
    }
  }

  return { ok: mismatches.length === 0, mismatches };
}
```

Notes:

- The AI engine often omits `stats` even though the REST endpoint computes them
  by default. Compare only the fields present in `data`.
- A recorded rate date can differ by a carry-back day around weekends and
  holidays. The percentages should still match for the same window.
- If the AI answer used a universe (a movers ranking) rather than a named list,
  query REST in movers mode and compare the currencies that appear in `data`.
- Growth+ is required for both the AI analysis and the REST analysis endpoint.

## Verify the semantics, not just the number

A number can be arithmetically correct and still be misread. Check these fields:

| Field | What to confirm |
|---|---|
| `asOf` | The rate reference date, not the time of the request. Values roll forward from the last trading day. |
| `from` and `to` (history) | The requested window. You may need REST's `startDate` and `endDate` to see the actual carry-back snapshot dates. |
| `from` and `to` (PPP) | Years, not dates. |
| `projected` (PPP) | `true` means the end factor is an IMF projection, not actual data. Do not present it as observed. |
| `strengthenedPct` | The direction figure for a currency against its base. Positive means stronger. |
| `changePct` (history) | The direction of the quoted rate. For a non-USD currency it usually has the opposite sign from `strengthenedPct`. |
| `crisis` (safe haven) | The reference crisis behind `crisisReturnPct`. Do not generalize it. |
| `bandPct` (stability) | The band used for `withinBand`. Do not assume 5 percent. |
| `maxDrawdownPct` | A peak-to-trough decline, usually negative or zero. Do not present it as a return. |
| `observations` | The sample size behind a volatility or correlation row. A small sample is less reliable. |

## Current limitations

- **No citations.** The response does not link a number to the REST endpoint or
  the underlying fixing. The mapping in [AI response schema](/docs/ai-response-schema/)
  is your guide for which endpoint to check.
- **No tool metadata.** `toolCalls` is a count. The response does not name the
  tools, their arguments, or their durations.
- **No outcome or kind.** A `200` can be an answer, a clarification, or a
  refusal. Inspect `answer` and `data`.
- **No usage block.** Token counts are not returned.
- **No streaming.** You cannot verify a partial answer.
- **No guarantee of prose accuracy.** The tools are deterministic; the model's
  description of them is not audited sentence by sentence.
- **No forecast data in AI answers.** Projected PPP values can appear in a PPP
  analysis row and are flagged with `projected`, but the assistant does not
  forecast FX rates.

When a number matters, verify it. When it does not, still display `asOf` so the
user knows how fresh the rate is.

## Related

- [AI endpoint reference](/docs/ai-endpoint/)
- [AI response schema](/docs/ai-response-schema/)
- [AI errors](/docs/ai-errors/)
- [/convert reference](/docs/convert-endpoint/)
- [/history/analysis reference](/docs/history-analysis-endpoint/)
- [/ppp/analysis reference](/docs/ppp-analysis-endpoint/)
