# @jocax/decision-core

A library that asks a decision model typed questions (yes/no, choice, score) about a piece of state and returns probabilities that code can branch on. It is a library, not a pi extension: it has no `pi` manifest, registers nothing and is usable outside pi.

## What

Three question types:

- `noul` — a yes/no question. The answer is the probability of yes.
- `choice` — pick one of a fixed set of option ids. The answer names the option and gives a probability per option.
- `score` — place the state on an ordered scale. The answer is a position from 0 to the number of levels minus 1.

Two providers serve this contract: `typesafe` (`https://api.typesafe.ai`, bearer key) and `ollama` (a local server, no key). A third provider, `mock`, answers offline by word overlap and needs no network.

## Why

Every `@ae-pi` package that uses a decision model shares one client: the same config names, the same limit checks, the same error codes and the same offline mock. Without it each consumer would write its own question builders, its own validation and its own test double.

## Install

The library is not loaded through the pi package filter; nothing imports it at pi startup. A package in this repository declares it as a dependency:

```json
{
  "dependencies": {
    "@jocax/decision-core": "*"
  }
}
```

npm workspaces link it by name, so the consumer imports it directly:

```ts
import { createDecisionClient, noul } from "@jocax/decision-core";
```

### From outside this repository

The package is published to the public npm registry as a build artefact:

```bash
npm install @jocax/decision-core
```

```ts
import { createDecisionClient, noul } from "@jocax/decision-core/built";
```

The bare specifier and `./built` both resolve to `dist/`: the tarball carries
compiled ESM and its declarations, never `src/`. `npm run build:check -w
@jocax/decision-core` compiles `src/` into a temp directory and diffs it
against the committed `dist/`, so the artefact cannot drift unnoticed.

## Usage

```ts
import { choice, createDecisionClient, noul, resolveDecisionConfig } from "@jocax/decision-core";

const { config, configured, error } = resolveDecisionConfig({ cwd: process.cwd() });
if (!config) throw new Error(configured ? error : "no decision provider configured");

const client = createDecisionClient(config);
const { answers } = await client.decide({ message: "Checkout is down for everyone." }, {
  urgent: noul("Does `message` convey urgency?"),
  team: choice("Which team should handle `message`?", {
    billing: "Charges, invoices, refunds",
    technical: "Bugs, outages, crashes",
    other: "None of the above",
  }),
});

if (answers.urgent.noul > 0.7 && answers.team.choice === "technical") {
  // page the on-call engineer
}
```

The answers are typed from the questions: `answers.urgent` is a noul answer and `answers.team.choice` is one of `billing`, `technical` and `other`, with no cast. A `noul` near 0.5 means the model is undecided, not that the answer is no. `confidence` says how concentrated the probabilities are, not how likely the answer is to be right. A score runs from 0 to the number of levels minus 1, and `normalizedScore(answer)` maps it to 0 to 1 so scales of different lengths compare.

## Configuration

Each setting is taken from the first place that has it: an override passed to `resolveDecisionConfig`, then the environment, then `.pi/decision-core.json` in the project, then the provider default.

| Setting | Environment variable | File key | Default |
|---|---|---|---|
| Provider | `AE_PI_DECISION_CORE_PROVIDER` | `provider` | none; unset means not configured |
| Model | `AE_PI_DECISION_CORE_MODEL` | `model` | `jev-latest` (typesafe), `nimble` (ollama) |
| Base URL | `AE_PI_DECISION_CORE_BASE_URL` | `baseUrl` | `https://api.typesafe.ai`, `http://localhost:11434` |
| Timeout in ms | `AE_PI_DECISION_CORE_TIMEOUT_MS` | `timeoutMs` | 30000 (typesafe), 120000 (ollama) |
| Keep-alive, Ollama only | `AE_PI_DECISION_CORE_KEEP_ALIVE` | `keepAlive` | the server's setting |
| API key | `AE_PI_DECISION_CORE_API_KEY`, then `TYPESAFE_API_KEY` for typesafe | not allowed | none |

An unset provider is not an error: `resolveDecisionConfig` returns `configured: false` and no config, so a consumer with no decision model still starts.

```json
{
  "provider": "ollama",
  "model": "nimble",
  "baseUrl": "http://localhost:11434",
  "timeoutMs": 120000,
  "keepAlive": "10m"
}
```

API keys are never read from the config file. An `apiKey` key there is ignored with a warning, because the file can be committed.

## Limits

| Provider and model | Choice options | Score levels | Questions | Body | Tokens |
|---|---|---|---|---|---|
| typesafe, any model | 1 to 255 | 2 to 10 | no limit checked | no limit checked | 64,000 per request; 32,000 for state plus the longest question |
| ollama `nimble` | 2 to 26 | 2 to 26 | 1 to 64 | 64 KiB | 8,192 for state plus all questions |
| ollama `tev1` | 2 to 26 | 2 to 26 | 1 to 64 | 64 KiB | 2,000 for state plus all questions |
| ollama, other models | 2 to 26 | 2 to 26 | 1 to 64 | 64 KiB | not checked by the client |
| mock | as typesafe | | | | |

A request over a limit is rejected before anything is sent. Token counts are estimated at 3.5 characters per token, which matches source code; prose is therefore rejected about 20% early. Ollama's own token-overflow and body-size errors arrive as the same `limit_exceeded` error, with `source: "server"` on the violation instead of `"client"`.

## Errors

Every failure is a `DecisionError` with a `code`:

| Code | Meaning |
|---|---|
| `config` | the client cannot be built from this config |
| `invalid_request` | malformed state or questions; nothing was sent |
| `limit_exceeded` | over a provider or model limit; see `violations` |
| `http` | non-success status after retries; see `status` |
| `network` | the request did not reach the provider |
| `timeout` | the total time budget ran out |
| `invalid_response` | the provider answered outside the contract |

A caller's abort is not a `DecisionError`: the call rejects with the signal's own reason. Treat `http`, `network`, `timeout` and `invalid_response` alike as "the model is unavailable"; they say nothing about the request itself.

## Testing with the mock

```ts
import { createDecisionClient, MOCK_CONFIG, NIMBLE_LIMITS } from "@jocax/decision-core";

const client = createDecisionClient(MOCK_CONFIG);
const forced = createDecisionClient(MOCK_CONFIG, { mockAnswers: { urgent: { type: "noul", noul: 0.12 } } });
const constrained = createDecisionClient(MOCK_CONFIG, { limits: NIMBLE_LIMITS });
```

`mockAnswers` replaces the computed answer for a question id, so a test can force a branch. `limits` replaces the table lookup, so a test can check a request against another model's limits without that model. A mock call runs the same validation, limit and response checks as a live one. The mock scores by word overlap between the state and the descriptions; it says nothing about how a real model would answer.

## Examples

| File | Functions |
|---|---|
| `examples/level01-single-decisions.ts` | `injectionGate`, `urgentGate`, `classifyTicket` |
| `examples/level02-multiple-choice.ts` | `triageTicket`, `screenResume`, `qualifySponsorForm` |
| `examples/level03-composite-scoring.ts` | `ticketPriority`, `combinePriority`, `codeReviewRisk`, `ideaVerdict` |
| `examples/level04-confidence-gating.ts` | `gateShellCommand`, `decideCommandSafety`, `routeAccountAction`, `decideAccountAction`, `checkCitation`, `decideCitation` |
| `examples/level05-routing.ts` | `routeAgent`, `decideAgent`, `routeIntent`, `decideIntent`, `routeModel`, `decideModel` |

Each asking function takes the client as its first argument. The rule that turns answers into a decision is a separate pure function, so it can be tested with fixed answers.

## Live tests

The live suites are skipped unless `AE_PI_DECISION_CORE_LIVE` names the provider to test:

```bash
AE_PI_DECISION_CORE_LIVE=ollama npx vitest run software/packages/decision-core/tests/live.test.ts
AE_PI_DECISION_CORE_LIVE=typesafe npx vitest run software/packages/decision-core/tests/live.test.ts
```

They resolve the rest of their config from the ambient environment, so `AE_PI_DECISION_CORE_MODEL` must be unset or name a model of the provider under test. The Ollama suite needs Ollama 0.35 or later with the model pulled. The TypeSafe suite needs `AE_PI_DECISION_CORE_API_KEY` or `TYPESAFE_API_KEY`.

## Run the demo

```bash
just demo-web decision-core
```

decision-core is a T0 demo: web only. The herdr lane refuses it
(`demo: decision-core is a T0 demo; it runs on the web surface only`).

This is the one demo with no agent and no sandbox: the browser calls
`demo/entry.ts#decide` directly in the demo server's process. The first five
scenarios use the offline mock `mock/mock-decision-1` — no key, no network,
deterministic. The last two put one question to real models.

| Step | Do this | Watch |
|------|---------|-------|
| 1 | Pick "Yes or no" and Run | `{"type":"noul","noul":0.935}`. Running it again gives the same answer |
| 2 | Edit the state to something unrelated to the question, and Run again | `noul: 0.5` — the mock finds no overlap either way, which is undecided, not a confident no. Both calls stay in "Earlier calls" so the two are comparable |
| 3 | Pick "Pick one option" and Run | `choice: "transport"`, a `probabilities` entry per option, `confidence: 0.7075` |
| 4 | Pick "Position on a scale" and Run | `score: 2.99` on a 0–3 scale, with a `legend` naming each level and a `probabilities` map concentrated on the top one |
| 5 | Pick "A question the limits refuse" and Run | The Answer slot shows, in red: `DecisionError: Request exceeds the limits of mock/mock-decision-1: score "too_few_levels" has 1 levels; allowed 2–10` |
| 6 | Pick "Ask Ollama" and Run | `provider: "ollama"`, `model: "nimble"`: `checks_auth` `noul: 0.967`, and `layer` picks `auth` at 0.89. About 0.3 s on a warm local model |
| 7 | Pick "Ask TypeSafe" and Run | `provider: "typesafe"`, `model: "jev-1.13.0"` (the `jev-latest` alias resolved): `noul: 0.96`, `layer` `auth` at 0.99. About 0.4 s |

Every mock answer carries `provider: "mock"`, `model: "mock-decision-1"`,
`usage` and `meta`; calls take 0–1 ms. The mock scores token overlap between
the state and each description, so it reproduces the shape of a real answer
and nothing about its quality.

Steps 6 and 7 show the difference. Their state — "The login route compares a
submitted password against a stored bcrypt hash and rejects a mismatch." —
shares no word with the criteria ("a credential, a token or a session"), so
the mock answers `noul: 0.5` on it. Both real models recognise it as an
authentication check.

The real-model scenarios need what a consumer needs:

- **Ollama:** a local Ollama on `http://localhost:11434` serving `nimble`
  (decision-core's defaults).
- **TypeSafe:** `TYPESAFE_API_KEY` or `AE_PI_DECISION_CORE_API_KEY` in the
  demo server's environment. `just` loads the repository's `.env`, so a key
  there is enough. Without one the Answer slot shows
  `provider "typesafe" needs AE_PI_DECISION_CORE_API_KEY or TYPESAFE_API_KEY`.

Any scenario can be pointed at a real model by adding `"provider": "ollama"`
or `"typesafe"` to its input. The call runs in the demo server's process with
your own environment, not in a sandbox copy.

The mock scenarios need no model and no herdr.

## Development

```bash
npx vitest run software/packages/decision-core
npx tsc --noEmit -p software/packages/decision-core/tsconfig.json
```

The typecheck is part of the contract: the answer types are a compile-time feature and vitest does not check them.

## Attribution

The mock's scoring and the question texts of the examples are adapted from a MIT-licensed project; see `THIRD-PARTY-NOTICES.md`.

## Publication

This package is published to the public npm registry as a build artefact of a
private repository. Only `dist/` and the package's own documentation ship; no
source is published. There is no support and no contribution process: issues
and pull requests are not invited, and `repository` is intentionally absent
from the manifest so nothing points at a 404.
