> ## Documentation Index
> Fetch the complete documentation index at: https://docs.brussle.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Your own numbers as features

> Put the numbers you already keep (amounts, seat counts, ages, counts from related records, the engine's last answer) into the fit beside the engine's answer. Applied only when they beat the engine on your held-out outcomes.

The numbers you already keep can go into the correction beside the engine's answer: a plan's seat count, an invoice amount, how long ago a contract was signed, how many invoices are overdue, what the engine said last time. The fit weighs them against your outcomes, so numbers the engine does not weigh can move the probability, and can reorder your documents. The engine never reads a feature, so a feature costs no engine call.

A judgment with features is applied, like any correction, only when it beats the engine's own numbers on your held-out outcomes. A `bool`, a `choice` and a `score` can all have features, and a judgment does not need relations to have them.

## Add features

Name them in `features`. This judgment reads an account's notes, and keeps the account's seats, the days since it signed, and the engine's previous answer:

```json theme={"theme":{"light":"css-variables","dark":"css-variables"}}
{
  "name": "renewal_risk",
  "type": "bool",
  "applies_to": {"attributes.kind": "account"},
  "question": "Do the account's latest notes describe a plan to cancel or to move to a competitor?",
  "context": {"fields": [{"path": "state.notes", "max_chars": 2000}]},
  "features": ["state.seats", "age(attributes.signed_at)", "previous.p"],
  "horizon": "60d",
  "engine": {"name": "jev", "version": "current"}
}
```

* `state.seats` is the number on the account itself.
* `age(attributes.signed_at)` is the days since `signed_at`, measured from the account's own `updated_at` at the revision Brussle judged.
* `previous.p` is the engine's own `p` the last time it judged the account. It is missing for the first answer.

Each answer shows its values in `features`, and so does its evaluation. A write that changes only a feature, such as `state.seats`, re-records the answer with no engine call and no charge. With an age feature, so does a write of the same content, because the age moved. From 100 outcomes with 20 of each kind, the fit can weigh the features with the engine's answer.

**A feature change is a version change.** Adding, removing or renaming a feature makes a new version, with its own fit. The earlier version's answers read `stale` once you activate the new one. A new version starts with the engine's `p` until its own outcomes are enough.

## What a feature can read

A judgment keeps up to 12 features, from any of these sources:

| Feature | Value |
| - | - |
| `<relation>.count`, `.count_where`, `.labelled`, `.<sum\|avg\|min\|max\|latest>(<path>)` | An aggregate that the recipe's relations declare. See [features from relations](/concepts/relations#numbers-from-relations-features). |
| `state.<path>`, `attributes.<name>` | The value on the judged document itself. A number is itself. `true` is 1 and `false` is 0. Text, an object, `null` or no value is missing. |
| `age(created_at)` | How many days old the judged document is. |
| `age(state.<path>)`, `age(attributes.<name>)` | How many days have passed since a time on the judged document, such as `age(attributes.signed_at)`. |
| `age(<relation>.<latest\|min\|max>(<path>))` | How many days have passed since a time on related documents, such as `age(tickets.latest(created_at))`. The relation must declare the aggregate. |
| `previous.p` | The engine's own `p` the last time it judged this document. On a `choice`, its `1 - escape_p`. Not on a `score`. |
| `<from>.each(<path>)` | On a choice among candidates only: one number for each candidate. See [features on a choice among candidates](#features-on-a-choice-among-candidates). |

**Ages.** An age is a number of days, with a fraction: 36 hours is 1.5.

* Each age is measured from the judged document's own `updated_at`, at the revision Brussle judged, never from a related document's write or the clock. A write to a related document never moves an age. A new revision of the judged document does, even with the same content.
* The time must be an RFC 3339 string, such as `"2026-10-01T09:30:00Z"`. A number, such as seconds since 1970, or other text is missing.
* A time later than the judged document's `updated_at` gives a negative age.
* `age(updated_at)` is refused: at the judged revision it is always 0. Name the time you mean, such as `age(attributes.approved_at)`.

**`previous.p`** is the engine's `p` from the most recent time it judged the document, with the same engine version, under a version of the judgment that asks the same way.

* It is missing for the first answer, after the document is deleted and created again, after the engine version changes, and on a composite, which has no single `p`.
* It is the engine's `p`, never the corrected one.
* When only a feature moves, it stays the same (see [reuses](/concepts/evaluations#reuses)).
* Outcomes [replayed](/train/engine-changes) after the engine changes read it as missing, because their earlier answers came from the other engine.

**What Brussle refuses.** A feature on a path that an [outcome rule](/train/outcomes#record-outcomes-from-your-own-writes) reads, where one path is the same as the other or inside it, is `invalid_request`: for example `state.refund.amount` and a rule `on` `state.refund`. Such a feature would read the outcome itself. Brussle checks this when you create a version and when you set rules, against every version of the judgment. A rule's path on a related document is not checked.

## Ask about what will happen

The engine answers well about what a document shows now, and near chance about what will happen. For a question about what will happen:

1. Ask a narrow question about what the document shows now.
2. Keep counts from related documents as `features`, with `"render": false` so the engine does not read them.
3. Set a [`horizon`](/train/horizons), and record what happened as outcomes.
4. Let the fit on your outcomes weigh the engine's answer and the counts.

This judgment asks what the customer's messages say about an invoice, and keeps one count: the customer's invoices paid late in the last year. The outcome records whether the invoice went overdue within 30 days.

```json theme={"theme":{"light":"css-variables","dark":"css-variables"}}
{
  "name": "goes_overdue",
  "type": "bool",
  "applies_to": {"attributes.kind": "invoice"},
  "question": "Do the customer's messages about this invoice dispute it or ask for more time to pay?",
  "context": {
    "fields": ["state.number", "state.amount", "state.due"],
    "related": {
      "messages": {
        "match": {"attributes.kind": "message"},
        "join": {"theirs": "attributes.invoice_id", "mine": "id"},
        "last_n": 3,
        "fields": ["created_at", {"path": "state.body", "max_chars": 600}]
      },
      "paid_late": {
        "match": {"attributes.kind": "invoice", "attributes.status": "paid_late"},
        "join": {"theirs": "attributes.customer_id", "mine": "attributes.customer_id"},
        "window": "365d",
        "aggregate": {"count": true, "render": false}
      }
    }
  },
  "features": ["paid_late.count"],
  "horizon": "30d",
  "engine": {"name": "jev", "version": "current"},
  "freshness": {"policy": "on_change"}
}
```

**Measure what the engine adds.** In the [report](/train/report#how-well-answers-rank), each epoch's `held_out_metrics` scores `features` (the features and the engine's answer together) and `features_only` (the features alone) on the same held-out outcomes. If `features_only` scores as well as `features`, the engine adds nothing your counts do not already carry, and a [feature threshold](/train/thresholds#a-threshold-on-one-feature) on the counts is enough.

## How the fit with features works

* **One probability.** The answer has no `calibrated` object. `p` is the selected correction, applied when the answer is read, and `raw.p` is the engine's own answer, whose meaning never changes. `fit` says which correction `p` used: `id`, `interval`, `run`, `pinned`, `fitted_at`, `outcomes`, `method` and `from_previous_epoch`. `method` is `features` when the fit uses the features, and `early` or `full` when it corrects only the engine's `p`. `fit.interval` is the 95% interval of `p` at this answer's features. `fit` is `null` while `p` is the engine's.
* **Chosen like any fit.** Each run makes a fit that uses the features beside the fit on the engine's answer alone, under the same minimums. The fit with features is chosen only when it beats both the engine's `p` and the other fit on outcomes it did not use; the outcomes of one document are always held out together. If neither beats the engine, `p` stays the engine's. Below 100 outcomes the fit is reported and never applied (`readers_need_full`).
* **All readers use `p`.** Thresholds, filters, ranking, subscriptions and the [judgments that read it](/guides/roll-ups) use the corrected `p` (on a `choice` or `score`, the corrected numbers). A threshold on a feature still reads the stored feature. Groups cannot count a judgment with features. The evaluation log and the export keep the engine's `p` and the features.
* **Automate on the bound.** Queries and subscriptions can filter and rank on `answers.<j>.interval.lower` and `.upper`. See [automate on the bound](/train/correction#automate-on-the-bound).
* **Nothing is rewritten.** When the correction changes, each read uses the new one at once, and Brussle brings the judgments that read this one up to date, billed, without a confirm. This happens only when the fit `p` reads changes; a pin or a promote is confirmed first. See [events when the fit changes](/train/promotion#events-when-the-fit-changes) and [roll-ups](/guides/roll-up-rules#when-a-child-is-calibrated-differently).
* **Your thresholds on `p`.** You chose a threshold against the engine's `p`. Once `p` is corrected, the same threshold selects different documents, and `GET` on the judgment shows a `fit_changed` warning until you set a threshold again. The [threshold recommender](/train/thresholds) works from the corrected `p`.
* **Templates.** Each tenant reads the template's pooled correction until it has enough outcomes of its own. See [per-customer corrections](/train/tenants).

## Read each feature

In each epoch of the [report](/train/report), `held_out_metrics.features` gives the fit with features and reads each feature on its own, on the same held-out outcomes:

```json theme={"theme":{"light":"css-variables","dark":"css-variables"}}
{"features": {
  "log_loss": 0.31, "expected_calibration_error": 0.028, "auroc": 0.84,
  "precision_at_5": 0.61, "precision_at_10": 0.52,
  "engine_weight": 0.62,
  "per_feature": [
    {"name": "state.seats", "weight": -0.84, "center": 14.1, "scale": 9.7, "missing": 0.0,
     "without": {"log_loss": 0.37}, "alone": {"log_loss": 0.55}},
    {"name": "age(attributes.signed_at)", "weight": 0.21, "center": 180.0, "scale": 104.5, "missing": 0.02,
     "without": {"log_loss": 0.315}, "alone": {"log_loss": 0.64}},
    {"name": "previous.p", "weight": 0.03, "center": 0.24, "scale": 0.18, "missing": 0.41,
     "without": {"log_loss": 0.309}, "alone": {"log_loss": 0.66}}
  ]
}}
```

* **`engine_weight`**: how much the fit weighs the engine's answer. It is never negative, so for two answers with the same features, a higher raw `p` never reads lower.
* Entries come in the order of the definition.
* **`weight`**: how strongly one standard deviation of the feature moves `p`, so you can compare features in different units. Positive raises `p`; negative lowers it; near 0 means little importance. Here, more seats make a cancellation less likely, and older accounts a little more likely.
* **`center`** and **`scale`**: the feature's average and standard deviation over the outcomes the fit used. A missing value counts as `center`.
* **`missing`**: the share of those outcomes whose answer had no value. A feature that is often missing can say little.
* **`without.log_loss`**: the held-out log loss of the fit made again without this feature. Here, without seats the loss goes from 0.31 to 0.37.
* **`alone.log_loss`**: the held-out log loss of a fit on this feature alone, without the engine's answer.
* **`warning`**: `reads_like_the_outcome`, or absent.

The judgment's **Calibration** section shows the same list under **With features**, largest weight first.

### A feature that reads like the outcome

A number written once the outcome is known, such as a refund amount or a cancellation date, predicts the outcome almost perfectly. A fit on it looks very good on your past outcomes, but when an answer is needed the number is not there yet, and the fit is worth nothing. Brussle cannot know when you write a field, but it can see the result: when a feature alone scores below a tenth of the log loss of always answering `base_rate`, its entry has `"warning": "reads_like_the_outcome"`, and the **Calibration** section shows **reads like the outcome**.

1. Check when your system writes that field.
2. If it is written once the outcome is known, post a new version without the feature.

The warning is about your data. It never changes which fit answers read: Brussle chooses the fit on held-out outcomes only.

### Drop a feature that adds nothing

A feature whose `without` is not higher than the fit's own `log_loss` adds nothing the engine and the other features do not already give. A weight near 0 with `without` equal to `log_loss` marks a candidate. Drop it in a new version, and keep the budget of 12 for numbers that matter. Compare the new version's report with the old one before you activate it, or run a [shadow report](/guides/change-a-judgment#change-a-question-safely-with-a-shadow-report).

## Features on a choice or a score

A `choice` and a `score` take features as a `bool` does: the same sources, the same limit of 12, the same rules. Their fit weighs the features beside the engine's whole distribution.

* **The answer's numbers are the corrected ones:** `dist`, `escape_p` and `value` on a `choice`; `dist` and `score` on a `score`. `raw` holds the engine's: `{dist, value, escape_p}` or `{dist, score}`. There is no `calibrated` object, and `fit` names the correction as on a `bool`.
* **The pick can change.** `value` is the most probable option of the corrected `dist`. With features, the fit can make another option, or `none_of_the_above`, the most probable. Without features, the order among your options never changes: the fit only moves answers toward or away from `none_of_the_above`.
* **Every reader uses the corrected numbers:** thresholds on `dist`, filters and ranking on `escape_p`, `score`, `value` and `dist.<option>`, subscriptions, the judgments that read it and [links](/guides/linked-accounts). A new fit that changes a pick moves a link to the new document and brings its readers up to date, billed.
* **`fit.interval`** is on `escape_p` for a `choice` and on `score` for a `score`. Filter and rank on its bounds.
* **Chosen like any fit:** only when it beats the engine's numbers and the fit without features on outcomes it did not use.
* **In the report,** `held_out_metrics` scores the whole distribution, with `accuracy` (and on a `score`, `mean_level_distance`). On a fixed `choice`, `per_feature[].by_option` gives each feature's weight on each option, and `moved_picks` the share of picks the fit changes.

## Features on a choice among candidates

A choice among candidates (`options.from`) can keep two kinds of feature:

* **Numbers about the judged document,** such as `candidates.count` or the payment's `state.amount`. They help decide whether a match exists, which is `1 - escape_p`.
* **Numbers about each candidate,** `<from>.each(<path>)`, such as `candidates.each(state.open_amount)` or `candidates.each(age(created_at))`. They help decide which candidate it is. `<path>` is the candidate's `state.<path>` or `attributes.<name>`, or `age(created_at)`, `age(updated_at)` or `age(<state or attributes path>)` of the candidate, as of the judged document's revision. Each candidate the engine read keeps its numbers under the answer's `candidate_features`, by candidate `id`. They count toward the 12.

How they act:

* **A candidate the engine gave no probability stays at none.** The fit only weighs the candidates on the engine's shortlist.
* **An answer with no candidates stays `none_of_the_above`,** with `escape_p` 1, whatever the fit.
* **Without numbers about candidates, the engine's order among them is kept.** With them, a candidate your numbers favour can rise above one they do not, at equal engine confidence: the invoice with nothing paid against it can rise above one already settled.
* **Never a worse pick.** The fit is used only when, on outcomes it did not use, its pick is right at least as often as the engine's. A pick is right when it names the true match, or the escape where there was no match. When numbers about candidates would make it pick worse, Brussle fits again without them, keeping the engine's order.
* **A candidate's answers cannot be a feature.** `candidates.each(answers.<judgment>.<part>)` is `invalid_request`: through a [link](/concepts/relations#documents-a-matcher-linked), it would read the matcher's own pick back into its next answer. Keep the number on the candidate's `state` and name that.
* **Not under the promotion rule yet:** each run's fit replaces the current one.

In the report, `held_out_metrics` reads whether a match exists for the four readings, with the ranking, and `held_out_metrics.candidates` gives the rest:

* **`joint`**: the held-out log loss of the whole answer (the escape and every candidate): raw, with the fit without features, and with the fit with features. Brussle chooses on it.
* **`selection`**: how often the pick was right, raw (`raw_accuracy`) and corrected (`fitted_accuracy`), over every outcome, so a fit that rightly says there is no match counts as picking right. `guard.state` is `fitted`, `kept_engine_order` (with both accuracies before the fit was made again), or `too_few_matched` (fewer than 20 outcomes name a match among two or more candidates, so the engine's order holds). `accuracy_holds` is `true` when the fit picks at least as well.
* **`moved_picks`**: the share of the outcomes the fit used whose pick it changes. It is a number to read, not a condition.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.