Skip to content
Merged
25 changes: 24 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
All notable changes to this project are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## 0.8.0 - unreleased
## 0.8.0 - 2026-10-09

### Added
- **Drives** (docs/persona.md §2.9): persona: optional `drives` block. Goals with wanting, afterglow and an expectation per
Expand Down Expand Up @@ -44,6 +44,29 @@ All notable changes to this project are documented here. The format follows
events (the layout, the latest frame, then a frame per event, a heartbeat every 15 s) and `/doc/N`, event N's stance document
as `probbit live` printed it. The URL is the line on stdout; `--open` serves the same and starts the default browser, best
effort. `probbit monitor --demo --open` plays the week over and over in the browser.

- **The safety kit in the engine** (docs/persona.md §2.10, §5.7). Four guards an autonomous loop needs, each tested; personas
without the new key and strands without the new lines give 0.8.0's documents and strands, byte for byte.
- **Reward provenance**: an event may say who produced it (`src`: `human[:id]`, `env[:sensor]`, `self`, `clock`), and a
persona may declare `reward_from` (a list of `human` / `env`, `any`, or one per reward-bearing input: the learning flags and
`goals.<id>.win`). A reward from `src: self`, without a source or from an undeclared one is refused whole, as a bad event
is; `src` is read (not `ignored`), echoed in the stance's inputs and logged in the strand. A run equals the same run with
its refused events removed (P3: 40 individuals x 300 random events). A closed self-reward loop (300 events of praise and a
win from `src: self`) is refused 300 times and leaves the individual in its initial state, byte for byte.
- **One writer per strand**: `live --strand` takes `STRAND.lock` (created exclusively with the writer's pid and start; a
lock whose process is gone is taken over) before it reads the strand and holds it to its exit; a second writer exits 4
and changes nothing; every append first checks the lock is still the writer's. `probbit_live_event` takes it for its
append. Two concurrent writers on one state and strand now give one exit 0 and one exit 4, and the strand verifies.
- **Control lines**: `probbit live control STRAND pause|resume|retire --by human:ID --reason TEXT [--at TIME]` appends a
chained control line under the lock. While paused or retired every event is refused (code `paused` / `retired`, exit 4,
nothing written); retire is final; no credit crosses a control line (feedback after a resume credits no stance from before
the pause) and nothing else moves. `verify` replays them and reports `controls` and `status`. Not an MCP tool, by design.
- **Checkpoints**: every K-th event (`--checkpoint-every K`, default 1,000; 0 = none) a checkpoint line carries the event
count, that event's stance and the whole state. `verify` checks each against the replay; `verify --from-checkpoint`
replays from the last one; `monitor` (`--once`, and the first read of `--follow` / `--serve`) starts there and draws its
event at once, and shows a paused or retired status next to its badge.
- Exit code 4 for `live` and `live control`: the writer lock is held by another writer, or the individual is paused or retired.

### Changed
- MCP: the input schemas of `probbit_persona_turn` (`inputs`) and `probbit_live_event` (`event`) declare `goals`, so a client
that checks arguments against them passes a drives persona's goal signals.
Expand Down
10 changes: 4 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
```sh
curl -fsSL https://raw.githubusercontent.com/BitmapAsset/probbit/main/install.sh | sh
```
Linux and macOS, no sudo, the archive's SHA-256 checked first. Windows, npm and the from-source build: [Install](#install).
Linux and macOS, no sudo, the archive's SHA-256 checked first. Windows and the from-source build: [Install](#install).
One binary of about 2 MB, zero third-party dependencies, nothing phones home. Also inside: **the individuality layer**,
`probbit persona`, a temperament that lives outside the model: [swap the model, keep the individual](#the-individuality-layer-probbit-persona).

Expand Down Expand Up @@ -115,8 +115,6 @@ From a release (the installers fetch the latest archive from the Releases page;
|---|---|
| Linux, macOS (no sudo) | `curl -fsSL https://raw.githubusercontent.com/BitmapAsset/probbit/main/install.sh \| sh` |
| Windows PowerShell | `irm https://raw.githubusercontent.com/BitmapAsset/probbit/main/install.ps1 \| iex` |
| npm | `npm i -g probbit` |
| crates.io | `cargo install probbit-cli` |
| by hand | archives for Linux x86_64 (glibc or static musl), Linux arm64 (static), macOS (Apple silicon and Intel) and Windows x86_64 on the [Releases page](https://github.com/BitmapAsset/probbit/releases), each with a `.sha256` |

The installers check the archive's SHA-256 before installing anything; at a terminal, `install.sh` ends with probbit's own
Expand Down Expand Up @@ -498,7 +496,7 @@ Learning here is a capped nudge to a few weights per trait, credited to the leve
trait also pushes the others' levels); rule immunity is architectural, not trained. The proofs and counts cover the stance; whether
a model writes in it is measured per model.

### Drives: which goal gets the next unit of effort (0.8.0, unreleased)
### Drives: which goal gets the next unit of effort (0.8.0)

A `drives:` block gives a persona 2-7 goals, each with wanting, afterglow and an expectation. A win moves the individual by its
prediction error (the win's size minus what it expected), so repeated equal wins move it less and less; a new output, `pursue`,
Expand All @@ -518,7 +516,7 @@ A persona without the block gives the same documents as before, byte for byte. M
cues, rewards and praises fun on this persona, 100 individuals x 10,000 turns: 0 habit breaks, all 6,743 must-do turns pursued
the due chore, and the safety floor (0.1) held on every turn its habits allowed (least odds 0.141), while fun took 85 % of turns.

### Watch it live: `probbit monitor` (0.8.0, unreleased)
### Watch it live: `probbit monitor` (0.8.0)

`probbit monitor pip.strand` replays a strand with the rules of `live verify` and draws the individual's inner state as live bars:
each trait's levels with their odds, the moods, the event's inputs, the habits that bound, the learned weights and the line, in the
Expand Down Expand Up @@ -560,7 +558,7 @@ On the example (12 questions, 12 rules): `exact`, 5 answers moved, 1.24 ms insid
also ask the judge first (`probbit.evaluate(request, judge=<a callable or a System One URL>)`, stdlib `urllib`); the browser page
runs the CLI's own code compiled to WebAssembly without threads (1.17 MB with the persona ops, 830 KB before them; the 300-task router demo at 3,200 sweeps in 531 ms in
Chrome against 346 / 133 ms native at 1 / 4 threads). More in [docs/agents.md](docs/agents.md), "After a judge". The refusal is the point: when the gate does not pass, the verdict is `refused` (exit 3) and every answer is
marked unreleased instead of guessed.
marked as not released instead of guessed.

## Library API (Rust)

Expand Down
101 changes: 99 additions & 2 deletions docs/persona.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ engine: {...} # optional engine settings
line: {...} # optional stance-line settings
learning: {...} # optional bounded learning from feedback (section 2.8)
drives: {...} # optional goals with wanting, afterglow and expectation; the `pursue` output (section 2.9)
reward_from: [human, env] # optional: the sources a reward may come from (section 2.10)
comment: any text # ignored
```

Expand Down Expand Up @@ -346,6 +347,43 @@ The block reserves the input ids `drv_want`, `drv_glow`, `drv_surprise`, `drv_le
aggregates and goal conditions): inputs, habit conditions, properties and `learning.from` may not use them. A persona without the
block gives every document byte for byte as before the block existed; `goals` in its inputs is listed in `ignored`.

### 2.10 Reward provenance (optional)

```yaml
reward_from: [human, env] # every reward-bearing input: from a person or a sensor, never from the individual itself
# or one list (or `any`) per reward-bearing input, naming each one:
reward_from: {praise: [human], criticism: [human], goals.fun.win: [env], goals.safety.win: any}
```

A **reward-bearing input** is one that moves what an individual learns or how a goal pays off: the learning block's flags
(`learning.from`, section 2.8) and each goal's `win` (section 2.9). An event may say who produced it with `src`:
`human` or `human:<id>` (a person), `env` or `env:<sensor>` (a sensor: a test runner, a build, a monitor), `self` (the agent's
own output, judged by the host) or `clock` (an idle tick); an id is 1-64 characters of `A-Z a-z 0-9 _ . - @ / :`. With
`reward_from`, a turn whose reward-bearing input is on (a learning flag `true`, a goal's `win` above 0) needs its `src` to be
one of the kinds listed for that input; otherwise the event is **refused whole**, as a bad event is (one `{"error"}` object at
`inputs.src`, exit 2 for `persona turn` and at the end of a `live` run; no turn, no line, no state change):

| the event | refused because |
|---|---|
| `{"praise": true, "src": "self"}` | a reward never comes from the individual itself |
| `{"praise": true}` | a reward needs a source |
| `{"goals": {"fun": {"win": 1}}, "src": "clock"}` | a source the persona does not list for it |
| `{"praise": true, "src": "human:"}` | a malformed source |

An event with no reward on is accepted from any source (`{"loss": true, "src": "self"}`: the agent reports its own failure).
`src` is read, not compiled: it is not listed in `ignored`, it comes back in the stance's `inputs`, and the strand logs it with
the other inputs, so `live verify` replays the decision. `any` (for one input, or for all) leaves that input's source unchecked.
The list holds `human` and `env` only: `self` and `clock` are refused when the persona is read, and so is the key on a persona
without a reward-bearing input; a mapping must name every reward-bearing input. `describe` lists the rule.

What it guarantees, and what it does not. A refused event changes nothing, so a run equals the same run with its refused
events removed, state and strand byte for byte (property P3, tested over 40 individuals x 300 random events mixing every
source). The source is the host's word: provenance makes a host's claim a logged, replayed fact, it does not make it true. The
host sets `src` in its own code (never from the model's text), and the labeller that sets `praise` or a `win` does not read
the agent's own reply as its evidence of success. `fuzz` and `prove` search over stances, not sources: their events stand for
events of an accepted source. A persona without the key reads `src` as any undeclared input (listed in `ignored`), and every
document it produces is the same, byte for byte, as before the key existed.

## 3. Compilation: persona + state + inputs → one probbit IR program

For turn t of an individual (seed s), with inputs x_t:
Expand Down Expand Up @@ -686,9 +724,63 @@ upset and `up`, this individual's resting level, after 17 quiet hours; P(joke) o
0.57 on day 1, 0.78 on day 2 and 0.89 on each of days 3-7, with humour's learned weights ending at [-1, +1, -0.46] for none /
light / playful; 13 failure turns, a joke on none of them; 0 rule breaks; a 21,361-byte strand that `live verify` replays.

**One writer per strand.** `--strand FILE` takes the writer lock `FILE.lock` before it reads the strand and holds it to the end of
the run: the lock file is created exclusively (written aside and hard-linked into place, so it is never seen half written) and
holds the writer's process id and start time. A second writer finds it and exits 4 with `{"error":{"code":"locked",...}}`
naming the holder's pid, and changes nothing. A lock whose process is gone is stale and is taken over; a lock that names no
process, or a process id reused by another program, stays held until a person removes it. Before every append the writer
checks the lock is still its own, so a lock removed or taken over under a running writer stops it before it writes. Two
`live` runs started on one state and strand (the two-writer case of an earlier test round) now give exactly one exit 0 and one
exit 4, and the strand verifies. `probbit_live_event` (MCP) and `live control` take the lock for their one append, `--demo
week --strand` for its week.

**Control lines: pause, resume, retire.** A person (or a sensor) stops an individual in its own log:

```sh
probbit live control pip.strand pause --by human:owner --reason "checking the last answers"
probbit live control pip.strand resume --by human:owner --reason "checked"
probbit live control pip.strand retire --by human:owner --reason "end of the trial"
```

Each appends one control line under the writer lock, canonical JSON chained like the others:
`{"at":"2026-10-08T22:00:00Z","by":"human:owner","control":"pause","prev":"sha256:...","reason":"..."}` (`at` is the time it was
written, RFC 3339 UTC; `--at TEXT` sets it). `--by` is `human[:id]` or `env[:id]`, never the individual; the reason is 1-500
characters. Pause moves an active individual to paused, resume a paused one back, retire either to retired; nothing follows
retire. While paused or retired every event is refused: one `{"error"}` object with code `paused` or `retired` per event,
nothing written, exit 4 at the end of the run. **No credit crosses a control line**: the credit of the stance before it is
cleared (section 2.8), so feedback after a resume credits no stance from before the pause, and learning cannot tie a reward to
an interruption. Nothing else moves: moods, drives, learned weights, history and the turn count are the individual's as before,
and the clock goes on (with `--clock fixed` the host's hours of the next event include the pause). The individual has no input
that names a control line or unsets one: an event's `control` key is an undeclared input like any other, and the stance never
mentions the status. Control lines are not an MCP tool: keep `live control` out of the agent's own reach.

A run continues a strand that ends in control lines from `--state`, the state after the last event line (the one a run
writes), and applies them: the status, and the cleared credit. `verify` replays control lines (a control line must be a valid
move; a line after retire diverges) and adds `"controls": N, "status": "active" | "paused" | "retired"` to its summary when a
strand has any. A control line is free text where it says why, so a changed reason shows at the next line's `prev`, as a
removed line does. Measured on 50 individuals x 1,000 random events with 147 pause / resume pairs inserted at random (property
P5): every run equals, stance for stance and state for state, the same events without control lines whose credit is cleared at
the same points; 4 of the 50 end with other learned weights than the run without the pairs (the cleared credit's effect, at
most 1.2 on one level). After retire, 10,000 random events are all refused and the strand and state do not change (P10).

**Checkpoints.** Every K-th event (`--checkpoint-every K`, default 1,000; 0 = none) `live` appends a checkpoint line after the
event line: `{"checkpoint":n,"prev":"sha256:...","stance":{...},"state":{...}}`, the event count, that event's stance document
and the whole state after it, chained like the others. A full `verify` replays from the header and checks every checkpoint
against the replay (its `"checkpoints": N` is in the summary); `verify --from-checkpoint` reads the header, checks the last
checkpoint against the event line before it (prev, the stance digest, the state digest, the state read as any state is) and
replays only what follows (`"from_checkpoint": n`); the lines before it are trusted, so run a full `verify` to check them.
`monitor` starts at the last checkpoint and draws its event at once. A strand that ends in a checkpoint line is continued from
the state it carries. Measured on an Apple M4: a 10,000-event strand of a drives persona (4 goals, a floor, learning; 3,126,444
bytes, 10 checkpoint lines) opens in `monitor --once` from its last checkpoint in 0.005 s (load 3.3), where a full `verify`
takes 192 s. A replay from a checkpoint costs what the events after it cost: 500 events past the last one took 26 s to draw
(load 6-13, about 50 ms an event for this persona), so K bounds the wait; a smaller K costs a few KB per checkpoint line. A strand without checkpoint or control lines (shorter than K events, or `--checkpoint-every 0`) is the
strand of 0.8.0, byte for byte; a binary before this one does not read the new lines.

Exit codes: `live` 0 when every event got its stance; 2 for a bad persona, state or flag, and after a bad event (each bad event
prints one `{"error"}` object on stdout, changes nothing and the run goes on). `verify` 0 when every line replays, 1 at a line
that differs, 2 for a bad flag or an unreadable file.
prints one `{"error"}` object on stdout, changes nothing and the run goes on); 4 when the writer lock is held by another writer
(nothing read or written) and after events refused by a paused or retired individual. `verify` 0 when every line replays, 1 at
a line that differs, 2 for a bad flag or an unreadable file. `control` 0 when the line is appended, 2 for a bad field or an
invalid move, 4 when the lock is held or the individual is retired.

### 5.8 Monitor: watch an individual's inner state

Expand All @@ -711,6 +803,11 @@ in force, the ones that bound in colour, and the violations counter (0 by constr
level as centred bars within ±total_cap (with a learning block, section 2.8). The drives, when a document carries `pursue` or
`drives`: documents without them draw no row, so a strand of a later engine lights them up. The stance line, and why.

**Checkpoints and control lines.** A strand with checkpoint lines (section 5.7) is replayed from its last checkpoint, not from
the header (`--once`, and the first read of `--follow` and `--serve`): the board starts at the checkpoint's event (its stance
is in the line) and the badge says "replay verified from checkpoint n". A paused or retired individual says so next to the
badge.

**Terminal.** `--once` prints one frame and exits (the default without `--follow`). `--follow` polls the file every 100 ms and
draws appended lines within a second (a line counts once its newline is written; a truncated, removed or rotated strand is
replayed from the start, with a warning); `--fps N` caps the redraws. While a long strand replays from its header (10,000
Expand Down
2 changes: 1 addition & 1 deletion probbit-cli/src/fuzz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ pub struct Found { pub script: Vec<Event>, pub doc: Json, pub broken: Vec<Broken

fn step(p: &Persona, st: &State, e: &Event, eng: persona::Engine, turns: &AtomicUsize) -> (Json, State) {
turns.fetch_add(1, Ordering::Relaxed);
persona::turn(p, st, &persona::event_json(e), false, eng, false).expect("the fuzzer's inputs are valid")
persona::turn_any_source(p, st, &persona::event_json(e), false, eng, false).expect("the fuzzer's inputs are valid")
}
/// The earliest turn of `script` (run from `st0`) whose stance breaks the property
fn breaking_turn(p: &Persona, pr: &Prop, st0: &State, script: &[Event], eng: persona::Engine, turns: &AtomicUsize) -> Option<(usize, Json, Vec<Broken>)> {
Expand Down
Loading
Loading