guideme 0.2.1

Type-safe inline judgments from TypeSafe Jev: if / match / score as Rust primitives
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
# guideme (Rust)

[![crates.io](https://img.shields.io/crates/v/guideme.svg)](https://crates.io/crates/guideme)
[![docs.rs](https://docs.rs/guideme/badge.svg)](https://docs.rs/guideme)
[![license](https://img.shields.io/crates/l/guideme.svg)](#license)

guideme is a Rust library for [TypeSafe Jev](https://docs.typesafe.ai), the TypeSafe model that
gives judgments. It is for Rust programs that make a decision about text or data, for example
where to send a support ticket. You send a question and your *state* (the data to judge, for
example a ticket). guideme returns the answer as a normal Rust value. A yes/no question gives a
`bool`. A choice gives a variant of your own enum, and a score gives one of your own ordered
levels.

## Install

```sh
cargo add guideme
cargo add tokio --features rt-multi-thread,macros
```

guideme needs Rust 1.98 or newer. The crate includes `#[derive(Choice)]` and
`#[derive(Levels)]`, so do not add `guideme-derive` yourself. Set the API key in the
`TYPESAFE_API_KEY` environment variable, or give it to `Guide::builder().api_key(..)`.

## Quick start

```rust,no_run
use guideme::{choose, noul, Choice, Guide};

#[derive(Choice, Clone, Copy, PartialEq, Eq, Debug)]
enum Department {
    /// Payments, invoicing, refunds
    Billing,
    /// Bugs, outages, integrations
    Technical,
    /// Pricing, upgrades, new accounts
    #[guide(fallback)]
    Sales,
}

#[tokio::main]
async fn main() -> Result<(), guideme::Error> {
    let guide = Guide::from_env()?; // reads TYPESAFE_API_KEY
    triage(&guide, "I was charged twice this month").await
}

async fn triage(guide: &Guide, ticket: &str) -> Result<(), guideme::Error> {
    if guide.ask(noul("Should this ticket be escalated?"), ticket).await? {
        // escalate
    }

    let team = choose::<Department>("Which team should handle this?").min_confidence(0.6);
    match guide.ask(team, ticket).await? {
        Department::Billing => {}
        Department::Technical => {}
        Department::Sales => {} // also the answer when confidence is less than 0.6
    }
    Ok(())
}
```

Each variant of `Department` is an *option*: one possible answer of the choice. The doc comment
on each variant is its *rubric*: the text that tells the model what the option means. The
variant name in `snake_case` is the key of the option on the wire (in the HTTP request).
`#[guide(rubric = "…")]` replaces the doc comment, and `#[guide(key = "…")]` replaces the key.
The compiler makes sure that the `match` handles every option.

When your program starts, build one guide, as `main` does here. Then share it in the whole
program. A `Guide` is cheap to clone, and the clones share one HTTP client.

## Questions

A yes/no question is what TypeSafe calls a *noul*, and `noul` is its type on the wire. A choice
has one of your options as its answer. A score has one level of your ordered scale as its answer.
The *instructions* are the text of the question, the first argument of each constructor.

| Constructor | Asks | Plain answer | Detailed answer (`.detail()`) |
|---|---|---|---|
| `noul("…")` | a yes/no question | `bool` | `Verdict::{Yes, No, Unsure}(p)` |
| `choose::<C>("…")`, `C` derives `Choice` | a choice over the variants of `C` | `C` | `Ranked<C>`: confidence and all probabilities |
| `score::<L>("…")`, `L` derives `Levels` | a score over the levels of `L` | `L`, the most probable level | `Scored<L>`: the expected `value`, the level, confidence and distribution |
| `choose_among("…", options)` | a choice over `(key, rubric)` pairs that you give at run time | `Key`, the option key | `Ranked<Key>` |
| `score_levels("…", levels)` | a score over level rubrics that you give at run time | `Rank`, the level position from 0 | `Scored<Rank>` |

A `Choice` enum also needs `Clone`, `PartialEq` and `Eq`. A `Levels` enum also needs `Clone`,
`PartialEq`, `Eq`, `PartialOrd` and `Ord`. The levels of a score go from low to high in
declaration order, so you can compare them:

```rust,no_run
use guideme::{score, Guide, Levels};

#[derive(Levels, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
enum Frustration {
    /// Calm and polite
    Calm,
    /// Frustrated
    Frustrated,
    /// Very angry
    VeryAngry,
}

async fn is_upset(guide: &Guide, ticket: &str) -> Result<bool, guideme::Error> {
    let mood = guide.ask(score::<Frustration>("How frustrated is the customer?"), ticket).await?;
    Ok(mood >= Frustration::Frustrated)
}
```

A yes/no question can carry `.criteria("what yes means", "what no means")`. The instructions are
a string or a `serde_json::Value`. With a `Value`, a question can name fields of a structured
state, as the TypeSafe docs describe.

The state can be a text literal, a `String`, a `serde_json::Value`, or a reference to any type
that implements `Serialize`. An owned struct is not accepted, so pass a reference. If the state
does not convert to JSON (a map with keys that are not strings, for example), `ask` returns
`Error::Config`.

`guide.models()` returns the models that your account can use.

## When the model is not sure

The model does not answer with a plain yes or an option. For a yes/no question it gives `p`, the
probability of yes. For a choice or a score it gives a confidence. A *threshold* (`yes_above`,
`no_below`, `min_confidence`) is the limit that turns this number into an answer. *Unsure* means
that the number does not reach the thresholds. The *policy* is the set of thresholds.

| Layer | How to set it |
|---|---|
| question | `.yes_above(p)` and `.no_below(p)` on a yes/no question, `.min_confidence(c)` on a choice or a score, `.with(Policy)` on any question |
| guide | `Guide::builder().policy(..)`, or `guide.with_policy(..)?` for a copy with this policy merged over the policy of the guide |
| defaults | `yes_above` 0.5, `no_below` 0.5, `min_confidence` 0.0 |

A threshold on the question wins over the guide, and the guide wins over the defaults.

- Yes/no question: `p >= yes_above` is yes, `p <= no_below` is no, and a value between the two
  is unsure. With the defaults, no answer is unsure.
- Choice and score: `confidence < min_confidence` is unsure. With the default, no answer is
  unsure.
- A threshold outside 0..1, or a `no_below` that is more than `yes_above`, is `Error::Config`.

guideme resolves an unsure answer with the *unsure ladder*. The first step that applies wins:

1. The value from `.or(value)` on the question.
2. The *fallback*: the variant marked `#[guide(fallback)]`. Only a `#[derive(Choice)]` enum can
   declare one. For a score or a `choose_among` choice, use `.or(..)`.
3. `Error::Unsure`, with the question id, the probability or confidence, and the threshold that
   it did not reach.

`.detail()` skips the ladder and gives you the detailed answer, so it never fails on an unsure
answer. It also removes an `.or(..)` that you set before it. The policy setters are `const fn`,
so a policy can be a constant:

```rust,no_run
use guideme::{noul, ApiKey, Guide, Policy, Verdict};

const CAUTIOUS: Policy = Policy::new().yes_above(0.7).no_below(0.3);

async fn route(key: ApiKey, ticket: &str) -> Result<(), guideme::Error> {
    let guide = Guide::builder().api_key(key).policy(CAUTIOUS).build()?;
    // yes at 0.9 or more, no at 0.3 or less, unsure between the two
    let strict = guide.with_policy(Policy::new().yes_above(0.9))?;

    match strict.ask(noul("Is this about billing?").detail(), ticket).await? {
        Verdict::Yes(_) => {}                                 // billing
        Verdict::No(_) => {}                                  // everything else
        Verdict::Unsure(p) => println!("a human decides: {}", p.get()),
    }
    Ok(())
}
```

## Examples and counterexamples

A description alone can leave two similar options to chance. An *example* is an input that
belongs to an option. A *counterexample* is an input that does not.

```rust
use guideme::Choice;

#[derive(Choice, Clone, Copy, PartialEq, Eq, Debug)]
enum Department {
    /// Payments, invoicing, refunds
    #[guide(example = "My card was charged twice", example = "Where is my refund?")]
    #[guide(counterexample = "The dashboard is down")]
    Billing,
    /// Bugs, outages, integrations
    #[guide(example = "502 on every request")]
    Technical,
    /// Pricing, upgrades, new accounts
    #[guide(fallback, example = "Do you have a team plan?")]
    Sales,
}
```

The rubric of `Billing` goes on the wire as one string:

```text
Payments, invoicing, refunds
Examples: My card was charged twice; Where is my refund?
Not this option: The dashboard is down
```

- `example` and `counterexample` can repeat. They combine with `rubric`, `key` and `fallback`.
  `key` and `fallback` are for a choice only.
- A rubric with no examples and no counterexamples goes on the wire unchanged, byte for byte.
- A level can have examples, but not a counterexample. An example on a level says that such an
  input scores at that level.
- An input cannot be an example of two options or of two levels.
- An input cannot be an example and a counterexample of one option.
- An example or a counterexample cannot be empty, appear twice, contain a line break, or be on a
  variant with no rubric.
- An input can be an example of one option and a counterexample of another. This separates two
  options that are easy to confuse.

The derives refuse a broken rule at compile time. `.criteria`, `choose_among` and
`score_levels` apply the same rules at the time of `ask`, not in the constructor. A broken rule
then gives `Error::Config`. Each of the three takes a `Rubric` or a plain
description (anything that converts to `String`) as a rubric:

```rust,no_run
use guideme::{choose_among, noul, Guide, Key, Rubric};

async fn urgent(guide: &Guide, ticket: &str) -> Result<bool, guideme::Error> {
    let question = noul("Is this ticket urgent?").criteria(
        Rubric::new("Something is broken now and nobody can work around it")
            .example("the checkout page is down")
            .counterexample("a nightly job failed and we pull the numbers by hand for now"),
        Rubric::new("It can wait for the next working day"),
    );
    guide.ask(question, ticket).await
}

async fn desk(guide: &Guide, ticket: &str) -> Result<Key, guideme::Error> {
    guide.ask(choose_among("Which desk?", [
        ("returns", Some(Rubric::new("Whether an item can be returned")
            .example("Can I return these?")
            .counterexample("Has my return arrived yet?"))),
        ("tracking", Some(Rubric::new("Progress of a return already sent")
            .example("Has my return arrived yet?"))),
    ]), ticket).await
}
```

All the rubrics in one `choose_among` list have the same type. If every option in the list has
`None` as its rubric, the compiler cannot find that type, so write it once:
`[("a", None::<&str>), ("b", None)]`.

## Several questions in one request

A tuple of up to 8 questions is also a question. So is a `Vec` or a `BTreeMap` of questions,
and they can nest. The answer has the same shape. It comes from one request, in one
[span](#observability). Each question keeps its own policy.

```rust,no_run
use std::collections::BTreeMap;

use guideme::{choose_among, noul, score_levels, Guide, Rank};

async fn triage(guide: &Guide, ticket: &str) -> Result<(), guideme::Error> {
    let flags = BTreeMap::from([("spam", noul("Is it spam?")), ("vip", noul("Is it a VIP?"))]);
    let desks = [("returns", Some("Returns of items")), ("tracking", Some("Where a parcel is"))];

    let (urgent, desk, mood, flags) = guide.ask((
        noul("Is this urgent?").yes_above(0.7).no_below(0.3).or(false),
        choose_among("Which desk?", desks).min_confidence(0.6),
        score_levels("How frustrated?", ["calm", "annoyed", "angry"]).detail(),
        flags, // comes back as BTreeMap<&str, bool>
    ), ticket).await?;

    if urgent || mood.level >= Rank(2) || flags["vip"] {
        println!("prioritize, send to {}", desk.0);
    }
    Ok(())
}
```

The question ids are `q0`, `q1` and so on, in the order that guideme reads the shape. Tuple and
`Vec` items keep their order, and `BTreeMap` entries go in key order. The ids appear on the wire, in
errors and in events. A batch is atomic. If guideme cannot resolve one answer, the whole call
fails. Put `.or(..)` or `.detail()` on each question that can be unsure.

## The receipt

`ask_with_receipt` sends the same request and records the same span as `ask`. It returns a
`Receipt`: the answer, the versioned model that answered, and the token usage. If you asked for
an alias, the model is still a version such as `jev-1.13.0`. Input tokens are the billed ones.

```rust,no_run
async fn escalate(guide: &guideme::Guide, ticket: &str) -> Result<bool, guideme::Error> {
    let question = guideme::noul("Should this be escalated?");
    let receipt = guide.ask_with_receipt(question, ticket).await?;
    println!("{} input tokens from {}", receipt.usage.input_tokens, receipt.model.as_str());
    Ok(receipt.answer)
}
```

## Errors

Every function that can fail returns `Result<_, guideme::Error>`. `error.kind()` gives the
*error kind*, a short stable name that is also the `error.type` of a failed span. `Error` is
`#[non_exhaustive]`, so a `match` on it needs a `_` arm.

| Variant | Error kind | When |
|---|---|---|
| `Auth` | `auth` | `401`: the API key is missing or not valid |
| `Invalid { detail }` | `invalid` | `422`, with the response body |
| `RateLimited { retry_after }` | `rate_limited` | `429` after the last retry, or a `retry-after` of more than 30 s |
| `Overloaded { retry_after }` | `overloaded` | `529` after the last retry, or a `retry-after` of more than 30 s |
| `Transport(..)` | `transport` | connection, TLS, timeout, or a failure to read the body |
| `UnexpectedStatus { status, body }` | `unexpected_status` | a status that the contract does not define |
| `Protocol { detail }` | `protocol` | the response breaks the contract: a body that does not decode, the wrong answer kind, an option or level not in the rubric, a probability outside 0..1, a missing answer |
| `Unsure { question, value, threshold }` | `unsure` | the answer is unsure and nothing on the unsure ladder caught it |
| `Config { detail }` | `config` | bad thresholds, no API key, an empty batch, a state that does not convert to JSON, a rubric that breaks a rule, duplicate option keys, a wrong number of options (1 to 255) or levels (2 to 10), a base URL with no host or with credentials, a setting that an injected client already carries |

## Retries and timeouts

guideme retries `429`, `529`, and a request that did not reach a server. A refused or reset
connection and a failed TLS handshake are failures of this kind. `GET /v1/models` gets the same
retries as `POST /v1/systemone`, so a `429` on a `models()` call at startup does not stop your
program. guideme does not retry a timeout of any phase, or a failure to read the body.
[`docs/contract.md`](docs/contract.md) gives the reason.

The wait before a retry is `backoff × 2^attempt`, with a maximum of 30 s, plus up to 250 ms of
jitter. If the API sends a `retry-after` in whole seconds, guideme waits that long instead. If
the `retry-after` is more than 30 s, the call fails at once, and the error carries that
duration. `timeout` is one deadline for each attempt, so the longest call takes about
`(max_retries + 1) × timeout`, plus the waits.

If you give guideme your own `reqwest::Client`, that client classifies its errors. guideme still
retries what `reqwest` reports as a failure to connect. So a `connect_timeout` on your client
makes a connect timeout a retry. guideme itself never sets a `connect_timeout`.

## Testing your code

Point the guide at a mock server. Then your control flow runs without a network or an API key.

```rust
use guideme::{noul, Guide};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

const URGENT: &str = r#"{
  "model": "jev-1.13.0",
  "answers": { "q0": { "type": "noul", "noul": 0.95 } },
  "usage": { "input_tokens": 307, "output_tokens": 20 }
}"#;

#[tokio::test]
async fn an_urgent_ticket_is_escalated() -> Result<(), Box<dyn std::error::Error>> {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/v1/systemone"))
        .respond_with(ResponseTemplate::new(200).set_body_string(URGENT))
        .mount(&server)
        .await;

    let guide = Guide::builder()
        .api_key("test-key".into())
        .base_url(server.uri())
        .build()?;

    assert!(guide.ask(noul("Should this be escalated?"), "payouts down").await?);
    Ok(())
}
```

Add `wiremock = "0.6"` to your `[dev-dependencies]`. guideme does not add it for you. In the
mock body, use the question ids from [Several questions in one
request](#several-questions-in-one-request). Use `server.received_requests()` to make sure that
guideme sent the correct request.

To replace the *transport*, give guideme your own `reqwest::Client`. Do this for a proxy, a
client certificate, or a connection pool that the rest of your program shares:

```rust
use std::time::Duration;

use guideme::api::{reqwest, Client};
use guideme::Guide;

fn configured() -> Result<Guide, Box<dyn std::error::Error>> {
    let http = reqwest::Client::builder().timeout(Duration::from_secs(10)).build()?;
    let client = Client::builder("key".into())
        .base_url("https://api.typesafe.ai")
        .http(http)
        .build()?;
    Ok(Guide::builder().client(client).build()?)
}
```

Set the timeout on the `reqwest` client. `Client::builder(..).timeout(..)` beside `.http(..)` is
`Error::Config`. The `Client` carries the API key, the base URL, the retries, the backoff and the
timeout. If you also set one of these on `Guide::builder()`, `build` returns `Error::Config` with
its name. For this reason, `Guide::builder().from_env()` does not combine with `client(..)`.

## Observability

guideme emits `tracing` spans and events. It installs no subscriber, so you see nothing until
you add one. Add `tracing-subscriber`, then install the smallest subscriber:

```sh
cargo add tracing-subscriber --features env-filter
```

```rust,no_run
tracing_subscriber::fmt().with_env_filter("warn,guideme=info").init();
```

Each `ask` is one `guideme.ask` span, and each HTTP attempt under it is one more span. A
`models()` call has only the HTTP span. Each question gives one `guideme.answer` event.
guideme records the state only with `record_state(true)`, and never records the API key.
[`docs/observability.md`](docs/observability.md) has every field, and console and OTLP setups.

## Configuration

Each *setting* is a method on `Guide::builder()`. The second column shows what `from_env()`
reads.

| Setting | Environment variable | Default | Meaning |
|---|---|---|---|
| `api_key(key)` | `TYPESAFE_API_KEY` | none | required, unless `from_env` or `client` gives the key |
| `base_url(url)` | `TYPESAFE_BASE_URL` | `https://api.typesafe.ai` | the API origin |
| `model(Model)` | `GUIDEME_MODEL` | `jev-latest` | the model or alias |
| `policy(Policy)` | | the defaults above | the policy for every question of this guide |
| `max_retries(n)` | | 3 | retries for `429`, `529` and a failure to connect |
| `backoff(d)` | | 500 ms | the base of the exponential wait |
| `timeout(d)` | | 30 s | the deadline for each attempt |
| `record_state(on)` | | `false` | record the state on the span |
| `client(Client)` | | none | a `guideme::api::Client` with your own transport |

`Guide::from_env()?` reads the variables and builds the guide. It needs `TYPESAFE_API_KEY`.
`Guide::builder().from_env()?` reads them onto a builder that you continue to configure:
`Guide::builder().from_env()?.policy(CAUTIOUS).build()?`. `build` makes sure that the policy is
valid, so a bad policy fails at startup.

## Lower layers

- `guideme::api` is the exact wire mirror of `POST /v1/systemone` and `GET /v1/models`, plus
  `Client` for callers who build requests themselves.
- `guideme::api::reqwest` is the `reqwest` that guideme links. Use it, and your `Client` type
  always matches. A major `reqwest` update is a breaking change for guideme.
- `guideme::policy::resolve(&Answer, Thresholds) -> Outcome` is the pure decision function.
- [`docs/design.md`]docs/design.md records the design decisions, the measurements behind them,
  and the sharp edges.

## Other SDKs

Each guideme SDK is written from scratch in its own language. Each one passes the same 42
golden policy vectors under `spec/` and renders rubrics the same way, as
[`docs/contract.md`](docs/contract.md) states. So the same probability or confidence and the
same thresholds give the same answer in each SDK.

| Language | Package | Repository |
|---|---|---|
| Rust | [`guideme`]https://crates.io/crates/guideme | this repository |
| Python | [`guideme`]https://pypi.org/project/guideme/ | [guideme-python]https://github.com/pedro-pscunha/guideme-python |
| TypeScript | `@guideme/sdk` (not yet on npm) | [guideme-typescript]https://github.com/pedro-pscunha/guideme-typescript |

## Development

Tools come from [mise](https://mise.jdx.dev). Run `mise install`, then `mise run hooks` once.
`mise run check` runs the full gate. [`CONTRIBUTING.md`](CONTRIBUTING.md) is the short guide.
[`AGENTS.md`](AGENTS.md) has the full contributor rules, the commands and the live tests.
Report a vulnerability privately, as [`SECURITY.md`](SECURITY.md) describes.

## License

MIT or Apache-2.0, as you choose.