From 20c8529ad61326bcf9654f55aaee4c24b7c46fbe Mon Sep 17 00:00:00 2001 From: BitmapAsset Date: Fri, 2 Oct 2026 19:04:09 -0700 Subject: [PATCH] probbit persona: the individuality layer (CLI, MCP, Python, wasm) A persona file (YAML subset or JSON: traits with priors, moods with inertia, couplings, per-turn inputs, history features, habits as hard rules) + an individual's state + a turn's inputs compile to ONE probbit-ir program, run in process by probbit run's own instruction at fixed work. The stance (levels with exact odds, habits in force and bound, refusals, a why, a stance line of at most 40 estimated tokens) and the next state are canonical JSON. - probbit persona init|turn|replay|explain|diff|lint|check|compile| describe (probbit-cli/src/persona.rs); a strict YAML-subset reader (yaml.rs); a strict validator (one persona error object, exit 2) - one canonical number rule for every digest (docs/persona.md 5.2); SHA-256 in process, no dependency - MCP tools probbit_persona_init and probbit_persona_turn (stateless) - Python probbit.persona_init, persona_turn, persona_replay - probbit-wasm ops 4 and 5; the playground's "Meet three individuals from one persona" - docs/persona.md, a README section, docs/agents.md rows, CHANGELOG - examples/persona: three fictional personas (YAML and JSON) and their goldens, equal to an independent reference implementation's documents byte for byte (its program texts carry the pre-rename IR key) - tests: goldens byte for byte, the YAML subset's cases, 1,000-turn replays, habit properties over 2,160 random-persona turns, lint, the evaluate bridge, refusals, 600 malformed documents, Python 3.9, MCP answers == CLI answers, wasm documents == CLI documents python/test_mcp.py: the tool-list assertions now expect the two persona tools after the five existing ones. --- CHANGELOG.md | 15 + README.md | 53 +- docs/agents.md | 8 +- docs/persona.md | 440 +++++++ .../golden/ops-engineer/final-state.json | 1 + .../persona/golden/ops-engineer/lint.json | 1 + .../golden/ops-engineer/program-turn0.json | 1 + .../persona/golden/ops-engineer/state0.json | 1 + .../persona/golden/ops-engineer/workday.jsonl | 20 + .../golden/trader-assistant/final-state.json | 1 + .../persona/golden/trader-assistant/lint.json | 1 + .../trader-assistant/program-turn0.json | 1 + .../golden/trader-assistant/state0.json | 1 + .../golden/trader-assistant/workday.jsonl | 20 + .../persona/golden/tutor/final-state.json | 1 + examples/persona/golden/tutor/lint.json | 1 + .../persona/golden/tutor/program-turn0.json | 1 + examples/persona/golden/tutor/state0.json | 1 + examples/persona/golden/tutor/workday.jsonl | 20 + examples/persona/ops-engineer.json | 533 ++++++++ examples/persona/ops-engineer.yaml | 167 +++ examples/persona/trader-assistant.json | 472 +++++++ examples/persona/trader-assistant.yaml | 155 +++ examples/persona/tutor.json | 554 ++++++++ examples/persona/tutor.yaml | 172 +++ examples/persona/workday.json | 94 ++ playground/check.mjs | 28 +- playground/index.html | 41 +- probbit-cli/src/main.rs | 78 +- probbit-cli/src/mcp.rs | 26 +- probbit-cli/src/persona.rs | 1113 +++++++++++++++++ probbit-cli/src/sys.rs | 10 + probbit-cli/src/yaml.rs | 241 ++++ probbit-cli/tests/persona.rs | 401 ++++++ probbit-wasm/src/lib.rs | 32 +- probbit-wasm/tests/persona.rs | 36 + python/probbit.py | 80 +- python/test_mcp.py | 38 +- python/test_persona.py | 79 ++ 39 files changed, 4917 insertions(+), 21 deletions(-) create mode 100644 docs/persona.md create mode 100644 examples/persona/golden/ops-engineer/final-state.json create mode 100644 examples/persona/golden/ops-engineer/lint.json create mode 100644 examples/persona/golden/ops-engineer/program-turn0.json create mode 100644 examples/persona/golden/ops-engineer/state0.json create mode 100644 examples/persona/golden/ops-engineer/workday.jsonl create mode 100644 examples/persona/golden/trader-assistant/final-state.json create mode 100644 examples/persona/golden/trader-assistant/lint.json create mode 100644 examples/persona/golden/trader-assistant/program-turn0.json create mode 100644 examples/persona/golden/trader-assistant/state0.json create mode 100644 examples/persona/golden/trader-assistant/workday.jsonl create mode 100644 examples/persona/golden/tutor/final-state.json create mode 100644 examples/persona/golden/tutor/lint.json create mode 100644 examples/persona/golden/tutor/program-turn0.json create mode 100644 examples/persona/golden/tutor/state0.json create mode 100644 examples/persona/golden/tutor/workday.jsonl create mode 100644 examples/persona/ops-engineer.json create mode 100644 examples/persona/ops-engineer.yaml create mode 100644 examples/persona/trader-assistant.json create mode 100644 examples/persona/trader-assistant.yaml create mode 100644 examples/persona/tutor.json create mode 100644 examples/persona/tutor.yaml create mode 100644 examples/persona/workday.json create mode 100644 probbit-cli/src/persona.rs create mode 100644 probbit-cli/src/yaml.rs create mode 100644 probbit-cli/tests/persona.rs create mode 100644 probbit-wasm/tests/persona.rs create mode 100644 python/test_persona.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 332c85d..335180d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,22 @@ All notable changes to this project are documented here. The format follows ## 0.5.0 - unreleased +### Added +- **`probbit persona`, the individuality layer** (docs/persona.md): a persona file (YAML subset or JSON: traits with priors, + moods with inertia, couplings, per-turn inputs, history features, habits as hard rules in the probbit-ir vocabulary) + an + individual's state + a turn's inputs compile to one probbit-ir program, run in process at fixed work; the stance (levels with + exact odds, habits in force and bound, refusals, a why and a stance line of at most 40 estimated tokens) and the next state are + canonical JSON. Subcommands `init`, `turn`, `replay`, `explain`, `diff`, `lint`, `check`, `compile`, `describe`. A strict YAML + subset reader (probbit-cli/src/yaml.rs) and a strict validator (one `{"error": {"code": "persona", ...}}` object, exit 2). Three + example personas with goldens in examples/persona/; the documents equal an independent reference implementation's. +- **MCP tools `probbit_persona_init` and `probbit_persona_turn`** in `probbit mcp` (stateless; the CLI's documents). +- **Python `probbit.persona_init`, `persona_turn`, `persona_replay`** (python/probbit.py; tests python/test_persona.py). +- **probbit-wasm ops 4 (persona init) and 5 (persona turn)**; the playground's "Meet three individuals from one persona". +- `probbit persona ... --timing` reports the 1-minute load average next to its timings (`sys::loadavg`). + ### Changed +- `probbit mcp` lists seven tools (the two persona tools after the five before; their order and contracts are unchanged). + - **Renamed: pbit → probbit everywhere** (crates, binary, env vars, MCP tools, Python module, npm package). 'p-bit' remains the term for the probabilistic bit. No behaviour changes. - Crates `probbit-core`, `probbit-ir`, `probbit-decide`, `probbit-cli` (binary `probbit`), `probbit-wasm`; Rust paths diff --git a/README.md b/README.md index ea55b76..a17eb41 100644 --- a/README.md +++ b/README.md @@ -330,6 +330,53 @@ Build the binary once with `cargo build --release -p probbit-cli` (it lands in ` bindings and no MCP server yet; every script in `bench/` is an example of the subprocess pattern. `probbit --help` lists every flag of a command with its default, and the exit codes. +## The individuality layer: `probbit persona` + +Give an agent a temperament that lives outside the model. A **persona** is a small file (YAML subset or JSON): traits with +priors, moods with inertia, soft couplings, per-turn evidence, and habits that are hard rules. A seed makes an individual. Every +turn compiles persona + the individual's state + the turn's inputs into ONE probbit-ir program, answered exactly in process; the +**stance** (a level per trait with exact odds, the habits in force and the ones that changed it, a refusal when the engine cannot +vouch) comes with a short **stance line** a host puts into any model's prompt. The model still writes every word; the persona +decides how they are written, and the same individual answers whatever model the host calls. [docs/persona.md](docs/persona.md) +is the format; [examples/persona/](examples/persona/) has three fictional personas and their goldens. + +```sh +probbit persona init examples/persona/tutor.yaml --seed 2 --out pip.json # an individual: genes from the seed +probbit persona turn examples/persona/tutor.yaml --state pip.json --inputs '{"loss": true, "sentiment": "negative"}' +# "status":"ok", "humour":{"level":"none","p":1,...}, "warmth":{"level":"warm","p":0.952032,...}, habits active: no_jokes_on_loss, ... +# "line":"Stance: no jokes, be kind; one emoji at most; gentle; explain step by step; warm and encouraging; suggest what to try next; ask what they think first; casual." +# "why":"learner reports a failure -> humour none, valence down; learner upset -> valence down, humour none" (pip.json is now turn 1) +probbit persona replay examples/persona/tutor.yaml --seed 2 --script examples/persona/workday.json # 20 stances, the same bytes every run +probbit persona lint examples/persona/tutor.yaml # contradicting habits: confused_no_playful vs after_error_check, resolved by priority +``` + +```python +import probbit # python/probbit.py, stdlib only +state = probbit.persona_init("examples/persona/tutor.yaml", seed=2) +turn = probbit.persona_turn("examples/persona/tutor.yaml", state, {"loss": True}) +prompt_tail, state = turn["stance"]["line"], turn["state"] # the line goes after any cached prompt prefix +``` + +MCP: `probbit_persona_init` and `probbit_persona_turn` in `probbit mcp` (stateless: the persona, inline or a path, and the state go +in; the state comes back). Browser: the playground's "Meet three individuals from one persona" (probbit-wasm ops 4 and 5, no +threads). Every surface gives the CLI's documents byte for byte (tests/persona.rs, python/test_mcp.py, probbit-wasm/tests). + +Measured in this run (Apple M4, macOS 26.5.2, 2026-10-02; the three example personas): +- a whole turn in process (compile, engine, decode, digests): median 0.20-0.32 ms, p95 0.22-0.67 ms, of which the engine 0.09-0.18 + ms (median); N = 1,000 turns per persona, 1-minute load 3.3-3.5, `nice 10`; +- the stance line: 36-38 estimated tokens on average over the 20 workday turns, at most 40; +- individuals: three seeds of the tutor differ by a mean total-variation distance of 0.12-0.18 between their trait odds on the + workday script; the tutor and the ops engineer (two persona files) by 0.45; the same persona and seed by 0; +- habits: 0 violations over 2,160 turns of 12 random personas (every habit in force re-checked from the file), and each of the 481 + habits reported as binding is broken by the habit-free twin; +- determinism: 1,000-turn replays are byte-identical across processes (3 personas x 2 seeds); the documents equal an independent + reference implementation's on 5,460 turns (the 60 golden turns and 5,400 turns of random inputs). + +What a persona can NOT do: write, read or check text (text rules stay in the prompt or a checker); see what the host does not tell +it; be a safety gate (keep approvals and permissions in plain code); its odds are its own model's, not measured probabilities that +a user will like the reply; and it cannot make a model follow the line: whether a given model writes in the stance it is given has +to be measured per model (no such measurement has been made here). + ## After any judge: `probbit evaluate` Decision models (TypeSafe's Jev, Cloudflare's Clef and Clef-flash, local System One servers) are judges: content in, a @@ -520,8 +567,10 @@ or Windows. keeps every object's fields equal to the parser's). - [docs/agents.md](docs/agents.md): calling probbit from a shell, Python, Node, PowerShell, MCP agents and a browser, and `probbit evaluate` after a decision model. -- [python/](python/): `probbit.py`, a zero-dependency subprocess wrapper (with `evaluate` and a stdlib mock judge), its tests and - three examples. +- [docs/persona.md](docs/persona.md): `probbit persona`, the individuality layer: the persona file, the compilation, the stance and + state documents, the canonical JSON, what a persona can not do; [examples/persona/](examples/persona/): three personas + goldens. +- [python/](python/): `probbit.py`, a zero-dependency subprocess wrapper (with `evaluate`, the persona functions and a stdlib mock + judge), its tests and three examples. - [playground/](playground/): one static page that runs probbit in a browser (`probbit-wasm`, built by `playground/build.sh`). - [bench/](bench/): the scripts behind BENCHMARKS §2 and §5 (Python 3; the ILP baselines need `numpy` and `scipy >= 1.9`). - [CONTRIBUTING.md](CONTRIBUTING.md), [CHANGELOG.md](CHANGELOG.md). diff --git a/docs/agents.md b/docs/agents.md index 18d209e..115ae2b 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -80,7 +80,7 @@ mark with `[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)` a ## MCP: one line per agent `probbit mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio (JSON-RPC 2.0, one message per -line; stdout carries only protocol messages, logs go to stderr; it exits when stdin closes). Its five tools take the +line; stdout carries only protocol messages, logs go to stderr; it exits when stdin closes). Its seven tools take the commands' own documents and return the commands' own JSON, byte for byte: | tool | arguments | returns | @@ -90,6 +90,12 @@ commands' own documents and return the commands' own JSON, byte for byte: | `probbit_stats` | optional `sweeps`, `chains`, `threads` | `probbit stats` | | `probbit_demo` | optional `tasks`, `seed`, `hard` | `probbit demo` (a router document) | | `probbit_evaluate` | a System One request plus the optional `probbit` block ([probbit-ir-json.md](probbit-ir-json.md#decision-api-probbit-evaluate)) plus optional `flags` | `probbit evaluate` | +| `probbit_persona_init` | `persona` (the document) or `persona_path`, optional `seed` | `probbit persona init` (the state) | +| `probbit_persona_turn` | `persona` or `persona_path`, `state`, `inputs`, optional `flags` (`timing`, `no_inertia`) | `{stance, state}`: `probbit persona turn`'s stance and the state it writes ([persona.md](persona.md)) | + +The two persona tools run in the server's process and keep nothing between calls: pass the returned state back on the next turn and +put `stance.line` into the model's prompt (after any cached prefix). A refused or fallback stance is an answer; a bad persona, +state or input is a tool error with the `{"error": {"code": "persona", ...}}` object. `flags` are the command's flags without the dashes: `{"budget_ms": 200, "seed": 3, "summary": true}`; `summary: true` returns the compact answer (README, "First five minutes"). `infeasible` and `refused` / `declined` are answers; bad input diff --git a/docs/persona.md b/docs/persona.md new file mode 100644 index 0000000..c2d2e99 --- /dev/null +++ b/docs/persona.md @@ -0,0 +1,440 @@ +# probbit persona: the individuality layer (format 1) + +A **persona** gives an agent a stable individual temperament that lives outside the language model. It is a small file of +traits, moods, habits and evidence rules. Every turn it compiles, with the agent's state and the turn's inputs, into **one +probbit IR program**. probbit answers with the **stance** for that turn: a level for every trait with exact odds, the habits +that were in force and the ones that changed the outcome, a refusal when the engine cannot vouch, a one-line explanation, +and a short **stance line** (at most 40 tokens by default) that the host puts into whatever model writes the reply. + +- **Same persona file + a different seed = a different, stable individual.** The seed fixes each individual's small, + permanent offsets ("genes"); the same individual is recognisable on inputs it has never seen. +- **Swap the model, keep the individual.** The stance is computed without any model in the loop, so it is the same for every + model and every host, and it can be replayed byte for byte. +- **A persona does not write text.** It decides *how* the text is written (warm or plain, one line or a breakdown, a joke or + none, verify first, ask first). The model still writes every word. + +This document is normative for the file format, the compilation, the stance document and the state. It is implemented by +`probbit persona` (probbit-cli/src/persona.rs and yaml.rs, no dependency) and served on every surface with the same documents: the +CLI, `probbit mcp` (tools `probbit_persona_init`, `probbit_persona_turn`), the Python wrapper (`probbit.persona_init`, +`persona_turn`, `persona_replay`) and the browser module (probbit-wasm ops 4 and 5). The example personas and their goldens are in +[examples/persona/](../examples/persona/). An independent reference implementation of this format (Python) gives the same +documents byte for byte on every example turn (section 5.3). + +```sh +probbit persona init examples/persona/tutor.yaml --seed 2 --out pip.json # an individual: genes from the seed +probbit persona turn examples/persona/tutor.yaml --state pip.json --inputs '{"loss": true}' # this turn's stance; pip.json moves on +``` + +Contents: 1 Files · 2 Schema · 3 Compilation · 4 The stance document · 5 State, canonical JSON, replay · 6 What a persona can NOT +do · 7 Commands and surfaces · 8 A model-proposed stance (the `evaluate` bridge) · 9 Versioning + +## 1. Files + +A persona is one document, written either in **JSON** or in the **YAML subset** below; both forms parse to the same object +and have the same digest (`sha256` of the canonical JSON: keys sorted, no whitespace, UTF-8). + +**YAML subset.** Block mappings, block sequences (including `- key: value` items), one-line flow collections (`[a, b]`, +`{a: 1, b: [x, y]}`), plain / single-quoted / double-quoted scalars (double-quoted ones take JSON's escapes), numbers, +`true` / `false` / `null` / `~`, and `#` comments. Rejected with a line number, never guessed: tabs in indentation, anchors, +aliases, tags, block scalars (`|`, `>`), flow collections spanning lines, duplicate keys, a second document, numbers that are not +exact in a double (an integer beyond 2^53, `1e999`), and plain keys another YAML reader would not read as strings (`yes`, `no`, +`on`, `off`, `y`, `n`, `true`, `false`, `null`, numbers: quote them). A file that uses only this subset means the same thing to +every YAML 1.2 reader. A persona file whose name ends in `.json` is read as JSON (duplicate keys refused), any other as the subset. + +The document is **strict**: every field below is type-checked; an unknown field is an error at its path (e.g. +`traits[2].sprad: unknown field`), as for every probbit document. Habit rules are checked when the file is read, as strictly as +`probbit run` reads them, so a malformed rule fails at once and not only on the turns it is in force. The same holds for a turn's inputs and for the state: a bad +value is one `{"error": {"code": "persona", "path", "message"}}` object and exit code 2, never a silent coercion. + +## 2. Schema + +```yaml +probbit_persona: 1 # format version (required) +identity: # required + name: Rook # 1-64 chars: letters, digits, space, _ . - (genes are keyed by name + seed) + version: 1.0.0 # a string + seed: 1 # the default individual (integer >= 0); `init --seed N` makes another one + summary: a cautious ops engineer # carried, never read by the engine +traits: [...] # required, at least one (section 3) +moods: [...] # optional +couplings: [...] # optional soft links between traits / moods +inputs: [...] # optional per-turn evidence +history: [...] # optional features derived from past turns +habits: [...] # optional hard rules +agenda: {...} # optional ordered beats of a reply +engine: {...} # optional engine settings +line: {...} # optional stance-line settings +comment: any text # ignored +``` + +Identifiers (`id` of traits, moods, inputs, history features, habits, agenda steps, and level names) match +`[a-z][a-z0-9_]*` (at most 48 characters). Level names may not be YAML-reserved words (`yes`, `no`, `on`, `off`, `true`, +`false`, `null`, `y`, `n`). + +### 2.1 Traits and moods + +```yaml +- id: verbosity + levels: [terse, short, full] # 2-7 ordered levels, lowest first + prior: [0.5, 0.4, 0.1] # probabilities (normalised), or `logw: [...]` natural-log weights; default uniform + spread: 0.5 # individuality: s.d. (nats) of each individual's ordinal shift; default 0 + say: [1-2 sentences, a short paragraph, full detail] # phrase per level for the stance line ("" = say nothing) + fallback: short # level used when there is no stance (default: the prior's most likely level) + vouch: 0.5 # optional odds floor: below it the trait is "unsure" (default 0 = off) + hold: short # optional: the level an unsure trait is held at (needs vouch > 0) +``` + +A **mood** has the same fields plus `inertia` (0 <= k < 1, default 0.5: how much of the previous turn's mood carries over) +and `half_life_hours` (optional: idle time halves the carried mood). Traits are the stance; moods are slower internal +variables that shape it through couplings. Trait and mood ids share one namespace. + +**The core trait set.** Any trait may be declared; the eight below are recommended so that personas, hosts and tools +interoperate (the same ids and level names in every persona make individuals comparable across personas): + +| trait | levels (low → high) | what it sets in a reply | standard dimension it follows | +|---|---|---|---| +| `warmth` | cool, neutral, warm | pleasantries, encouragement | interpersonal warmth (agreeableness) | +| `directness` | gentle, balanced, blunt | verdict first vs softened | politeness strategy / assertiveness | +| `verbosity` | terse, short, full | length | quantity | +| `humour` | none, light, playful | jokes | playfulness | +| `formality` | casual, neutral, formal | register | formality of register | +| `initiative` | follow, suggest, lead | answer only / propose / act | proactivity | +| `caution` | bold, measured, careful | verification depth, risk talk | conscientiousness / risk appetite | +| `curiosity` | focused, open, exploring | follow-up questions, alternatives | openness | + +Recommended moods: `valence` (down, even, up) and `arousal` (calm, steady, keyed): the two axes of the circumplex model of +affect. Common custom traits: `emoji` (none, sparse, rich), `patience`. + +### 2.2 Couplings (soft) + +```yaml +- {vars: [valence, humour], align: 0.8} # ordinal alignment: + w x c(a) x c(b) +- vars: [caution, initiative] + table: {careful: {lead: -0.8}, bold: {follow: -0.3}} # sparse log-weights, level of a -> level of b -> w +``` + +`c(level)` is the centred level index, from -1 (lowest) to +1 (highest). A positive `align` makes the two variables move +together, a negative one opposite. Couplings are soft: they shape the odds; they never forbid anything. + +### 2.3 Inputs (per-turn evidence) + +```yaml +- id: sentiment + kind: level # flag | level | number + levels: [negative, neutral, positive] + default: neutral + say: {negative: user upset, positive: user upbeat} # used in the explanation + effects: # level -> target -> effect + negative: {valence: -1.2, warmth: 0.4, humour: -0.8} +- id: error + kind: flag # effects apply when the flag is true + reactivity_spread: 0.3 # individuality: each individual's gain on this input, exp(N(0, s^2)); default 0 + effects: {caution: 1.0, verbosity: -0.6, valence: -0.6} +- id: stakes + kind: number # 0..max (default max 1; outside it: an error): effects scale linearly with the value + effects: {caution: 1.6, humour: -1.0} +``` + +An **effect** on a trait or mood is a number (an ordinal shift: `w x c(level)`, so `+1.0` pushes toward the highest level), +a list (one log-weight per level) or a mapping (`{level: log-weight}`). An effect on an agenda step (`step.`) is a +number (positive = earlier) or per-slot log-weights. All weights are natural-log weights, so `+0.7` roughly doubles the odds. + +**Standard inputs** (recommended names, so one host can drive any persona; a persona ignores what it does not declare and +lists it in the stance's `ignored`): + +| id | kind | meaning | +|---|---|---| +| `sentiment` | level: negative, neutral, positive | the user's tone this turn | +| `error` | flag | the agent's previous action failed | +| `loss` | flag | the user reports a loss or bad news | +| `praise` / `criticism` | flag | the user praised / corrected the agent | +| `stakes` | number 0..1 | how costly a mistake would be | +| `time_pressure` | number 0..1 | how hurried the user is | +| `claim_done` | flag | the draft reply is about to report a task as done | +| `task` | level: chat, question, code, ops, explain (a persona may declare its own list) | what kind of turn this is | +| `elapsed_hours` | number (reserved) | idle time since the previous turn; decays moods by their `half_life_hours` | + +### 2.4 History features + +```yaml +- id: errors_in_row + of: error # a flag input + kind: streak # streak: consecutive turns with the flag, this one included; recent: count in the last `window` turns + cap: 3 # the value saturates at cap (default 5) + window: 5 # recent only (default 5) + effects: {caution: 0.5, verbosity: -0.4} # per unit of the value +``` + +History features are inputs computed from the state; they can drive effects and habit conditions. + +### 2.5 Habits (hard rules) + +A habit is a rule the stance obeys **by construction**: the engine only considers stances that satisfy every habit in force. + +```yaml +- id: no_jokes_on_loss + when: {loss: true} # optional; all conditions must hold (flag: true/false; level input: a level or a list; + # number or history feature: a number = at least, or {at_least, at_most}) + then: {humour: [none]} # trait/mood -> allowed levels: + # [levels] | {not: [levels]} | {at_most: L} | {at_least: L}, + # L = a level, prev, prev-1 or prev+1 (the previous turn's level, clamped to the range) + say: no jokes # phrase for the stance line + priority: 0 # used only to resolve conflicts (section 5.5); default 0 +- id: verify_before_reporting + rules: # raw probbit IR rules over trait / mood ids, level names and agenda steps + precedes: [{before: step.verify, after: step.report}] +- id: one_flourish_at_a_time + rules: + linear: [{terms: [[humour, playful, 2], [humour, light, 1], [emoji, rich, 2], [emoji, sparse, 1]], limit: 3}] +``` + +`rules` takes the probbit IR constructs exactly as in a program: `caps` (with `members`), `implies`, `tables` (1-3 variables, +`forbid` or `allow`), `precedes` (agenda steps), `linear` (whole-number weights) and `all_different`. Soft `pairs` belong in +`couplings`. A habit without `when` is always in force. The persona's fallback stance must obey every unconditional habit +(checked when the file is read). + +Examples of the three habits every persona author asks for: never joke when the user reports a loss (`when: {loss: true}`, +`then: {humour: [none]}`); verify before claiming done (`when: {claim_done: true}`, `then: {caution: [careful]}`, or a +`precedes` on agenda steps); after an error, shorter and more checks (`when: {error: true}`, +`then: {verbosity: {at_most: prev-1}, caution: {at_least: prev+1}}`). + +### 2.6 Agenda (optional) + +```yaml +agenda: + steps: [acknowledge, diagnose, fix, verify, report] # 2-8 beats + prefer: {acknowledge: 0.6, report: -0.8} # number = earlier (+) / later (-), or per-slot log-weights + say_order: true # put "order: a > b > c" in the stance line (default true) +``` + +Each step becomes a variable `step.` over ordered slots `s0 .. s(n-1)`, all different (a permutation). Habits order +steps with `precedes`. Keep the agenda free of couplings to traits: it is then its own component and costs nothing. + +### 2.7 Engine and line + +```yaml +engine: + op: decide # decide (exact tiers first, then the sampler + gate) | sample (sampler + gate only) + sweeps: 2000 # fixed work per chain if the sampler runs (deterministic) + polish_sweeps: 200 + exact_limit: 2000000 + chains: 4 + twin: true # also solve the habit-free twin in the same program, to report which habits changed the stance + values: positional # positional (level i -> l: small pair tables, fast) | semantic (level names: readable programs) + on_conflict: fallback # fallback | yield: when habits contradict, the lower-priority one yields (section 5.5) +line: + max_tokens: 40 # estimated tokens (max of chars / 4 and 4/3 per word) + order: [verbosity, caution, directness, warmth, humour, initiative, formality, curiosity] + prefix: "Stance: " +``` + +## 3. Compilation: persona + state + inputs → one probbit IR program + +For turn t of an individual (seed s), with inputs x_t: + +1. **Values.** `positional` (default): `l0 .. l(L-1)` (L = the largest number of levels) followed by the agenda slots; + `semantic`: every level name in order of first appearance, then the slots. Both give the same distribution and stance + (checked on every example). +2. **Trait variable** v, level l (allowed: its own levels): + `h(v,l) = ln prior(l) + gene_v x c(l) + sum over inputs i of gain_i x scale_i(x_t) x effect_i(v,l) + + sum over history features f of min(value_f, cap_f) x effect_f(v,l)`, + with `scale` = 1 for a true flag or the matching level, the value for a number input, 0 otherwise. +3. **Mood variable** m: `h(m,l) = ln prior(l) + gene_m x c(l) + a_t(m,l)`, where the **mood accumulator** + `a_t = k x d x a_(t-1) + (1 - k) x e_t`, e_t = the turn's input and history effects on m (with gains), k = `inertia`, + d = 0.5 ^ (elapsed_hours / half_life_hours) (1 without them). An event's total impact over time is e_t whatever k is; + inertia only spreads it out, so it smooths the mood without shifting the persona's average character. +4. **Agenda**: one variable per step over the slots, unary = `prefer` + step effects; `all_different` over the steps. +5. **Couplings** become `pairs` with a k x k `table` over the value alphabet. +6. **Habits in force** (conditions evaluated on x_t and the history): each `then` entry becomes a one-variable `tables` + allow-list (`prev` resolved against the previous turn's level); `rules` are copied as written. Inactive habits are not in + the program. +7. **Twin** (`engine.twin`): a copy of every trait, mood and step variable named `free.`, with the same unaries, + couplings and agenda structure and **no habits**, in the same program (an independent component, solved separately). +8. Every weight is rounded to 6 decimals; the program is serialised in a fixed order. Its sha256 is the turn's `program` + digest. + +**The instruction is `probbit run --op decide` at fixed work** (`--seed S --sweeps N --polish-sweeps M --exact-limit X --chains C`), +in process: the program is built as a document and handed to `probbit run`'s own parser and instruction (no text round trip, no +child process). `probbit persona compile` prints it; `probbit run` with those flags answers it identically. +- `run`, because a persona is a general constrained categorical program (levels, soft pairs, caps, implications, + precedences, linear budgets). `decide` is the task-router front-end (tasks to workers under quotas) and cannot express + per-level unaries, couplings or these rules without contortion. `evaluate` is for a judge's per-question probabilities + plus rules: it is the bridge when a model proposes the stance (section 8), not the per-turn path. +- `--op decide` answers exactly whenever the program is small or decomposable (every persona here: enumeration per + component, about 0.2-0.7 ms of engine time) and falls back to the sampler and the diagnostics gate when it is not (a + large, densely coupled character), which is where refusals come from. +- Fixed work makes the answer a pure function of the program and the seed even when the sampler runs. `polish_sweeps: 0` means + no plan polish (never the wall-clock one, which would make a sampled turn depend on the machine's speed). +- The turn seed: `S = first 4 bytes of sha256("probbit-persona/1|turn|||")`, masked to 31 bits. + +## 4. The stance document (one per turn) + +```json +{"probbit_persona_turn": 1, + "persona": {"name": "Rook", "version": "1.0.0", "digest": "sha256:..."}, "seed": 3, "turn": 5, + "status": "ok", + "engine": {"verdict": "exact", "tier": "components", "program": "sha256:..."}, + "stance": {"verbosity": {"level": "terse", "p": 0.81, "odds": {"terse": 0.81, "short": 0.17, "full": 0.02}, "released": true}, "...": {}}, + "mood": {"valence": {"level": "down", "p": 0.44, "odds": {"down": 0.44, "even": 0.53, "up": 0.03}, "released": true}}, + "agenda": ["acknowledge", "diagnose", "fix", "verify", "report"], + "habits": {"active": ["after_error_shorter", "high_stakes_careful"], "bound": ["after_error_shorter"], + "violations": 0, "conflict": [], "yielded": []}, + "unsure": [], "held": [], "escalate": null, + "inputs": {"error": true, "stakes": 0.8, "errors_in_row": 2, "...": "..."}, "ignored": [], + "line": "Stance: shorter than last time, check more; 1-2 sentences; verify before claiming anything; no jokes.", + "line_tokens": 24, + "why": "shorter than last time, check more (habit); last action failed -> caution careful, verbosity terse", + "state_digest": "sha256:..."} +``` + +- `level` is the variable's value in the engine's joint plan (the most likely stance that obeys every habit); `odds` are its + exact marginals (or gate-checked estimates on a sampled answer). A level can differ from its own most likely value because + the plan must fit the other traits (the IR's "plan or odds" rule): act on `level`, read uncertainty from `odds`. +- `habits.active`: habits in force this turn. `habits.bound`: the active habits the habit-free twin breaks, i.e. the ones + that changed the stance. `violations` is always 0 when there is a stance (checked independently by the reference + implementation on every turn). +- `status`: + - `ok`: the engine answered `exact` or `diagnostics_passed`. + - `partial`: some variables did not pass the gate (`released: false`); their phrases are left out of the line. + - `refused`: nothing is vouched for; the line carries only the habits in force (hard rules are always safe to state). + - `fallback`: no stance at all (the engine proved the habits contradict on this turn, or failed): levels = the `fallback` + levels, line = the non-conflicting habits or `neutral`, `escalate` says why. `habits.conflict` names a minimal set of + contradicting habits (deletion filter: one extra engine call per active habit, only on such turns). +- `unsure` / `held`: traits whose top odds are under their `vouch` floor; with a `hold` level they are re-solved clamped to + it (a second engine call), so the stance stays consistent with every habit. +- `line`: phrases by priority — (1) bound habits, (2) other conditional habits in force, (3) unsure notes, (4) traits off + this individual's resting level, (5) traits at rest, (6) agenda order — cut from the lowest priority until it fits + `max_tokens`. Unconditional habits that did not bind are not repeated (the trait phrases already carry them). +- `why`: the bound habits, then the two strongest live inputs with the two traits each pushes hardest. +- `held`: the unsure traits held at their `hold` level; such a trait also carries `odds_unheld`, its odds before the hold. +- Timing is never part of the stance: `--timing` adds a separate, non-canonical `timing` object (compile, engine and decode ms, + engine calls, the 1-minute load average). + +## 5. State, replay and determinism + +### 5.1 State + +```json +{"probbit_persona_state": 1, "persona": {"name": "...", "version": "...", "digest": "sha256:..."}, + "seed": 3, "turn": 5, + "genes": {"shift": {"warmth": -0.2135, "...": 0}, "react": {"error": 1.1847}}, + "mood": {"valence": [-0.31, 0.0, 0.12], "arousal": [0, 0, 0]}, # accumulators a_t (log-weights per level) + "history": {"error": [false, true, true]}, # recent flag values (as long as the longest window / cap) + "prev": {"verbosity": "short", "...": "...", "agenda": ["..."]}, # last turn's levels (for prev-relative habits) + "rest": {"warmth": "cool", "...": "..."}, # the individual's resting stance (no evidence), set at init + "digest": "sha256:..."} +``` + +The state is plain JSON the host stores between turns (a file, a database row, a session field). A turn refuses a state +whose persona digest differs from the persona file, whose own digest does not match, or whose genes do not match its seed. + +### 5.2 Canonical JSON and the number rule + +Every document a persona produces, and every text it hashes, is printed canonically: object keys sorted by code point, no +whitespace, UTF-8 (no `\u` escape except for control characters; `"`, `\`, `\b`, `\f`, `\n`, `\r`, `\t` escaped as in JSON), +and every number by one rule: + +- a number whose value is a whole number of magnitude at most 2^53 prints as an integer: `1`, `-2`, `0` (so `1`, `1.0` and `1e0` + are one number in every digest, and so are `0` and `-0.0`); +- any other number prints as the shortest decimal that reads back to the same double (an exact tie between two such decimals goes + to the even one), in plain notation when its decimal exponent e satisfies -5 < e < 16 (`0.43543`, `0.0001`, `123.25`) and in + exponent notation otherwise (`1e-05`, `1.5e-07`, `1.2e+16`): Python's `repr` of a float. + +The persona digest is the sha256 of the persona document printed so; the state digest of the state without its `digest` field; +`engine.program` of the program text, which takes the same number rule but keeps the compiler's key order. A host that rewrites a +state with another JSON library (Python writes `0.0` where probbit wrote `0`) still has the same state. + +### 5.3 Guarantees + +- A turn is a pure function of (persona file, state, inputs, engine version): the same four give the same stance and new + state, byte for byte (canonical JSON). Replaying a script from `init` reproduces every stance. +- The engine runs at fixed work, so this holds on the sampled tier too (the engine's documented fixed-work determinism). +- Genes use the platform's `ln`, `sqrt` and `cos` and are rounded to 4 decimals; weights to 6. A last-place difference in a + maths library could, in principle, flip a rounding boundary on another platform (not observed; not proven impossible). +- The documents equal an independent reference implementation's (Python, written first, from this document) byte for byte on + the example personas: their state0, all 20 workday stances and the final state (the goldens in examples/persona/golden/), and on + 5,400 further turns of random inputs over every input each persona declares, three seeds each, as written, with + `values: semantic` and forced onto the sampler, held, yielded, refused and fallback turns included (measured on 2026-10-02). + +### 5.4 Individuality and distance + +`gene_v = round(spread_v x z(name, seed, "shift", v), 4)` and `gain_i = round(exp(reactivity_spread_i x z(name, seed, "react", i)), 4)`, +with `z` a standard normal from Box-Muller on the first 16 bytes of `sha256("probbit-persona/1|" + parts joined by "|")`. +Genes depend on the name, the seed and the variable id only, so editing an unrelated part of a persona keeps its individuals. + +**Distance between two individuals** over a script (the same inputs for both): the mean, over turns and the traits both +declare with the same levels, of the total-variation distance between their trait odds (0 = identical, 1 = disjoint); and +the fraction of (turn, trait) where their levels differ. Identical persona + seed + script → 0 / 0. + +### 5.5 Contradicting habits + +Two hard habits can contradict on some turns (for example "after an error, shorter than last time" against "at high stakes, +at least a short paragraph" when the previous reply was already short). The engine then proves that no stance obeys them all +(`infeasible`). With `on_conflict: fallback` the turn has no stance (status `fallback`, `habits.conflict` lists the +contradicting habits, the line omits them). With `on_conflict: yield` the lowest-priority habit of the conflict yields (on a +tie, the later-declared one), the turn is re-solved, and `habits.yielded` and `escalate` say so. + +**Lint.** `lint` searches every single conditional habit and every pair of them (with all unconditional ones and every +previous level their `prev` restrictions read) for an empty stance, and reports each contradiction with its resolution. +Run it before shipping a persona. + +## 6. What a persona can NOT do + +- It does not write, read or check text. Text-level rules (banned phrases, exact formats, facts) stay in the prompt or in + a checker; the persona only chooses the stance the text is written in. +- It cannot see what the host does not tell it. Inputs are the host's labels; a wrong label gives a confidently wrong stance + (the same limit as any rule system: hard rules are only as good as the facts they read). +- It is not a safety gate. Plain rules give identical rule safety at a fraction of the cost; keep approvals, permissions + and destructive-action checks in plain code (and a model that reads the content). +- Its odds are the persona's own model odds, not a measured probability that the user will like the reply. They are only as + good as the persona's weights; calibrate them on labelled turns before reading them as probabilities. +- It does not guarantee the model follows the line. Whether a given model writes in the stance it is given has to be + measured per model (the stance is model-independent; the obedience is not). + +## 7. Commands and surfaces + +Before each model call: build the turn's inputs (the standard ones above, plus the persona's own), call `turn`, put `line` +into the prompt **after** the cached prefix (the line changes every turn; placed early it would invalidate a prompt cache), +store the new state. On `refused` or `fallback`, use the line as given (habits only) and log `escalate`. + +| command | what it does | +|---|---| +| `probbit persona init PERSONA [--seed N] [--out STATE]` | a new individual: genes from the seed, the resting stance (one engine call) | +| `probbit persona turn PERSONA --state STATE [--inputs JSON\|FILE] [--out STATE2] [--timing] [--no-inertia]` | the stance on stdout; the new state replaces STATE (written, then renamed: a killed turn never leaves half a state) or goes to `--out` | +| `probbit persona replay PERSONA [--seed N] --script JSON\|FILE [--out TRACE] [--no-inertia] [--timing]` | `init`, then every turn of a script: one stance per line; stderr: the trace's sha256 and the final state digest | +| `probbit persona explain PERSONA [--seed N] --script ... --turn K` | turn K in words: every contribution to every field, the joint odds, the habit-free twin, the line, the why | +| `probbit persona diff PERSONA [--seed A] [--other PERSONA2] [--seed2 B] --script ...` | the distance between two individuals (section 5.4) and their most different turn | +| `probbit persona lint PERSONA` | contradicting habits (section 5.5); exit 1 if one is unresolved | +| `probbit persona check PERSONA` / `describe PERSONA` | valid, its digest and sizes / its traits, moods, inputs, habits and agenda | +| `probbit persona compile PERSONA --state STATE [--inputs ...]` | the turn's probbit-ir program; its sha256 is the stance's `engine.program` | + +A script is a JSON list of per-turn input objects (or `{"turns": [...]}`; a turn may be `{"inputs": {...}, ...}`). Exit codes: +0 done (every turn status is an answer), 1 `lint` found an unresolved contradiction, 2 a bad persona, state, input or script +(ONE `{"error": {"code": "persona", "path", "message"}}` object on stdout) or a bad flag (stderr). The engine's resource controls +are `probbit run`'s (`PROBBIT_THREADS`, `PROBBIT_CPU_LIMIT`, `PROBBIT_MEM_LIMIT_MB`, `PROBBIT_PRIORITY`, the config file); they +never change a document. The persona's own `engine.chains` is used whatever `PROBBIT_CHAINS` says (it is part of the answer). + +- **MCP** (`probbit mcp`): `probbit_persona_init {persona | persona_path, seed}` -> the state; `probbit_persona_turn {persona | + persona_path, state, inputs, flags: {timing, no_inertia}}` -> `{stance, state}`. Stateless; the documents are the CLI's. A + refusal or a fallback is an answer; a bad persona, state or input is a tool error carrying the error object. +- **Python** (python/probbit.py, standard library only): `probbit.persona_init(persona, seed=None)` -> the state; + `probbit.persona_turn(persona, state, inputs, timing=False, no_inertia=False)` -> `{"stance", "state"}`; + `probbit.persona_replay(persona, script, seed=None)` -> the stances. `persona` is a file path or the document as a dict; bad + input raises `ProbbitInputError` (`.code == "persona"`, `.path`, `.message`). +- **Browser** (probbit-wasm, no threads): `probbit_call` op 4 = persona init, op 5 = persona turn, with the MCP tools' arguments + (the persona inline). The playground's "meet three individuals from one persona" runs three seeds of one persona side by side. + +## 8. A model-proposed stance (the `evaluate` bridge) + +When a judge model reads the conversation and proposes a stance, the persona decides: one `choice` question per trait (the +criteria are the levels' `say` phrases), the judge's probabilities combined with the persona's own unaries +(`logw = ln max(p_judge, 1e-6) + h(v,l)`), and the habits in force as the request's rules block. `evaluate` returns the most +likely stance that obeys every habit, the odds under the rules, and per trait whether the persona moved the judge's choice. With +the judge's weight at 0 the request is the turn's own program (its unaries as `probbit.logw`, its pairs and habits as +`probbit.rules`) and `probbit evaluate` gives the stance `probbit persona turn` gives, level and odds within 2e-6, on every example +turn (tests/persona.rs). + +## 9. Versioning + +`probbit_persona: 1` is this document. Additive fields keep the number; a change of meaning of an existing field increments +it. The stance document (`probbit_persona_turn`) and the state (`probbit_persona_state`) carry their own format numbers. diff --git a/examples/persona/golden/ops-engineer/final-state.json b/examples/persona/golden/ops-engineer/final-state.json new file mode 100644 index 0000000..8d4b17c --- /dev/null +++ b/examples/persona/golden/ops-engineer/final-state.json @@ -0,0 +1 @@ +{"digest":"sha256:b382d647aaebc5a8bbca5089b0fd0ba5cf69cccd239f5732664675d73fcb4076","genes":{"react":{"error":0.861,"praise":1.4611},"shift":{"arousal":0.389,"caution":-0.9174,"curiosity":0.0071,"directness":-0.5623,"formality":-0.2421,"humour":0.0302,"initiative":-0.2988,"valence":-0.0938,"verbosity":0.2229,"warmth":-0.5951}},"history":{"error":[false,false,true,false,false]},"mood":{"arousal":[-0.15218,0,0.15218],"valence":[-0.830352,0,0.830352]},"persona":{"digest":"sha256:45919fc382359af2233989826b3c23c9c7257afbf3076098230564863a8a8ca6","name":"Rook","version":"1.0.0"},"prev":{"agenda":["verify","acknowledge","diagnose","fix","report"],"arousal":"keyed","caution":"careful","curiosity":"focused","directness":"blunt","formality":"neutral","humour":"none","initiative":"suggest","valence":"even","verbosity":"terse","warmth":"neutral"},"probbit_persona_state":1,"rest":{"arousal":"steady","caution":"measured","curiosity":"focused","directness":"blunt","formality":"formal","humour":"none","initiative":"suggest","valence":"even","verbosity":"terse","warmth":"cool"},"seed":1,"turn":20} diff --git a/examples/persona/golden/ops-engineer/lint.json b/examples/persona/golden/ops-engineer/lint.json new file mode 100644 index 0000000..e5291b2 --- /dev/null +++ b/examples/persona/golden/ops-engineer/lint.json @@ -0,0 +1 @@ +{"conflicts":[]} diff --git a/examples/persona/golden/ops-engineer/program-turn0.json b/examples/persona/golden/ops-engineer/program-turn0.json new file mode 100644 index 0000000..6c0bd25 --- /dev/null +++ b/examples/persona/golden/ops-engineer/program-turn0.json @@ -0,0 +1 @@ +{"probbit_ir":1,"comment":"probbit persona Rook 1.0.0 seed 1 turn 0; active habits: no_bold_lead, report_after_verify","values":["l0","l1","l2","s0","s1","s2","s3","s4"],"vars":[{"id":"warmth","allowed":["l0","l1","l2"],"h":{"l0":-0.454722,"l1":-0.693147,"l2":-2.49222}},{"id":"directness","allowed":["l0","l1","l2"],"h":{"l0":-1.740285,"l1":-0.916291,"l2":-1.255447}},{"id":"verbosity","allowed":["l0","l1","l2"],"h":{"l0":-0.916047,"l1":-0.916291,"l2":-2.079685}},{"id":"humour","allowed":["l0","l1","l2"],"h":{"l0":-0.386875,"l1":-1.386294,"l2":-2.965532}},{"id":"formality","allowed":["l0","l1","l2"],"h":{"l0":-1.367338,"l1":-0.693147,"l2":-1.446073}},{"id":"initiative","allowed":["l0","l1","l2"],"h":{"l0":-0.905173,"l1":-0.693147,"l2":-1.908238}},{"id":"caution","allowed":["l0","l1","l2"],"h":{"l0":-2.078332,"l1":-1.049822,"l2":-1.428226}},{"id":"curiosity","allowed":["l0","l1","l2"],"h":{"l0":-0.700247,"l1":-0.916291,"l2":-2.295485}},{"id":"valence","allowed":["l0","l1","l2"],"h":{"l0":-1.80332,"l1":-0.356675,"l2":-1.99092}},{"id":"arousal","allowed":["l0","l1","l2"],"h":{"l0":-1.082147,"l1":-0.916291,"l2":-1.913585}},{"id":"step.acknowledge","allowed":["s0","s1","s2","s3","s4"],"h":{"s0":0.6,"s1":0.3,"s3":-0.3,"s4":-0.6}},{"id":"step.diagnose","allowed":["s0","s1","s2","s3","s4"],"h":{"s0":0.4,"s1":0.2,"s3":-0.2,"s4":-0.4}},{"id":"step.fix","allowed":["s0","s1","s2","s3","s4"],"h":{"s0":0.1,"s1":0.05,"s3":-0.05,"s4":-0.1}},{"id":"step.verify","allowed":["s0","s1","s2","s3","s4"],"h":{"s0":-0.2,"s1":-0.1,"s3":0.1,"s4":0.2}},{"id":"step.report","allowed":["s0","s1","s2","s3","s4"],"h":{"s0":-0.8,"s1":-0.4,"s3":0.4,"s4":0.8}},{"id":"free.warmth","allowed":["l0","l1","l2"],"h":{"l0":-0.454722,"l1":-0.693147,"l2":-2.49222}},{"id":"free.directness","allowed":["l0","l1","l2"],"h":{"l0":-1.740285,"l1":-0.916291,"l2":-1.255447}},{"id":"free.verbosity","allowed":["l0","l1","l2"],"h":{"l0":-0.916047,"l1":-0.916291,"l2":-2.079685}},{"id":"free.humour","allowed":["l0","l1","l2"],"h":{"l0":-0.386875,"l1":-1.386294,"l2":-2.965532}},{"id":"free.formality","allowed":["l0","l1","l2"],"h":{"l0":-1.367338,"l1":-0.693147,"l2":-1.446073}},{"id":"free.initiative","allowed":["l0","l1","l2"],"h":{"l0":-0.905173,"l1":-0.693147,"l2":-1.908238}},{"id":"free.caution","allowed":["l0","l1","l2"],"h":{"l0":-2.078332,"l1":-1.049822,"l2":-1.428226}},{"id":"free.curiosity","allowed":["l0","l1","l2"],"h":{"l0":-0.700247,"l1":-0.916291,"l2":-2.295485}},{"id":"free.valence","allowed":["l0","l1","l2"],"h":{"l0":-1.80332,"l1":-0.356675,"l2":-1.99092}},{"id":"free.arousal","allowed":["l0","l1","l2"],"h":{"l0":-1.082147,"l1":-0.916291,"l2":-1.913585}},{"id":"free.step.acknowledge","allowed":["s0","s1","s2","s3","s4"],"h":{"s0":0.6,"s1":0.3,"s3":-0.3,"s4":-0.6}},{"id":"free.step.diagnose","allowed":["s0","s1","s2","s3","s4"],"h":{"s0":0.4,"s1":0.2,"s3":-0.2,"s4":-0.4}},{"id":"free.step.fix","allowed":["s0","s1","s2","s3","s4"],"h":{"s0":0.1,"s1":0.05,"s3":-0.05,"s4":-0.1}},{"id":"free.step.verify","allowed":["s0","s1","s2","s3","s4"],"h":{"s0":-0.2,"s1":-0.1,"s3":0.1,"s4":0.2}},{"id":"free.step.report","allowed":["s0","s1","s2","s3","s4"],"h":{"s0":-0.8,"s1":-0.4,"s3":0.4,"s4":0.8}}],"pairs":[{"i":"valence","j":"humour","table":[[0.8,0,-0.8,0,0,0,0,0],[0,0,0,0,0,0,0,0],[-0.8,0,0.8,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0]]},{"i":"valence","j":"warmth","table":[[0.4,0,-0.4,0,0,0,0,0],[0,0,0,0,0,0,0,0],[-0.4,0,0.4,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0]]},{"i":"arousal","j":"verbosity","table":[[-0.6,0,0.6,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0.6,0,-0.6,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0]]},{"i":"arousal","j":"caution","table":[[0.5,0,-0.5,0,0,0,0,0],[0,0,0,0,0,0,0,0],[-0.5,0,0.5,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0]]},{"i":"warmth","j":"formality","table":[[-0.5,0,0.5,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0.5,0,-0.5,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0]]},{"i":"humour","j":"formality","table":[[-0.6,0,0.6,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0.6,0,-0.6,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0]]},{"i":"directness","j":"verbosity","table":[[-0.4,0,0.4,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0.4,0,-0.4,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0]]},{"i":"caution","j":"initiative","table":[[-0.3,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,-0.8,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0]]},{"i":"free.valence","j":"free.humour","table":[[0.8,0,-0.8,0,0,0,0,0],[0,0,0,0,0,0,0,0],[-0.8,0,0.8,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0]]},{"i":"free.valence","j":"free.warmth","table":[[0.4,0,-0.4,0,0,0,0,0],[0,0,0,0,0,0,0,0],[-0.4,0,0.4,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0]]},{"i":"free.arousal","j":"free.verbosity","table":[[-0.6,0,0.6,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0.6,0,-0.6,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0]]},{"i":"free.arousal","j":"free.caution","table":[[0.5,0,-0.5,0,0,0,0,0],[0,0,0,0,0,0,0,0],[-0.5,0,0.5,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0]]},{"i":"free.warmth","j":"free.formality","table":[[-0.5,0,0.5,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0.5,0,-0.5,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0]]},{"i":"free.humour","j":"free.formality","table":[[-0.6,0,0.6,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0.6,0,-0.6,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0]]},{"i":"free.directness","j":"free.verbosity","table":[[-0.4,0,0.4,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0.4,0,-0.4,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0]]},{"i":"free.caution","j":"free.initiative","table":[[-0.3,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,-0.8,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0]]}],"implies":[{"if":{"var":"caution","value":"l0"},"then":{"var":"initiative","in":["l0","l1"]}}],"precedes":[{"before":"step.verify","after":"step.report","gap":1}],"all_different":[{"vars":["step.acknowledge","step.diagnose","step.fix","step.verify","step.report"]},{"vars":["free.step.acknowledge","free.step.diagnose","free.step.fix","free.step.verify","free.step.report"]}]} diff --git a/examples/persona/golden/ops-engineer/state0.json b/examples/persona/golden/ops-engineer/state0.json new file mode 100644 index 0000000..62a51f3 --- /dev/null +++ b/examples/persona/golden/ops-engineer/state0.json @@ -0,0 +1 @@ +{"digest":"sha256:4af41189ddd4b494eed0fa730d7ed08b527115a0eebfc0129bf6331686a5e1aa","genes":{"react":{"error":0.861,"praise":1.4611},"shift":{"arousal":0.389,"caution":-0.9174,"curiosity":0.0071,"directness":-0.5623,"formality":-0.2421,"humour":0.0302,"initiative":-0.2988,"valence":-0.0938,"verbosity":0.2229,"warmth":-0.5951}},"history":{"error":[]},"mood":{"arousal":[0,0,0],"valence":[0,0,0]},"persona":{"digest":"sha256:45919fc382359af2233989826b3c23c9c7257afbf3076098230564863a8a8ca6","name":"Rook","version":"1.0.0"},"prev":{"agenda":["acknowledge","diagnose","fix","verify","report"],"arousal":"calm","caution":"careful","curiosity":"focused","directness":"blunt","formality":"neutral","humour":"none","initiative":"suggest","valence":"even","verbosity":"terse","warmth":"neutral"},"probbit_persona_state":1,"rest":{"arousal":"steady","caution":"measured","curiosity":"focused","directness":"blunt","formality":"formal","humour":"none","initiative":"suggest","valence":"even","verbosity":"terse","warmth":"cool"},"seed":1,"turn":0} diff --git a/examples/persona/golden/ops-engineer/workday.jsonl b/examples/persona/golden/ops-engineer/workday.jsonl new file mode 100644 index 0000000..55610d3 --- /dev/null +++ b/examples/persona/golden/ops-engineer/workday.jsonl @@ -0,0 +1,20 @@ +{"agenda":["acknowledge","diagnose","fix","verify","report"],"engine":{"program":"sha256:5212f8079cfca0052a417c778d43c2a81af8fc9f237210be4151c25216b8e194","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["no_bold_lead","report_after_verify"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"criticism":false,"error":false,"errors_in_row":0,"loss":false,"praise":false,"sentiment":"neutral","stakes":0,"task":"question","time_pressure":0},"line":"Stance: 1-2 sentences; blunt: state the problem first; plain and businesslike; no jokes; propose the next step; formal and precise; stay on the task.","line_tokens":38,"mood":{"arousal":{"level":"steady","odds":{"calm":0.319458,"keyed":0.245112,"steady":0.43543},"p":0.43543,"released":true},"valence":{"level":"even","odds":{"down":0.331189,"even":0.602306,"up":0.066506},"p":0.602306,"released":true}},"persona":{"digest":"sha256:45919fc382359af2233989826b3c23c9c7257afbf3076098230564863a8a8ca6","name":"Rook","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"measured","odds":{"bold":0.158541,"careful":0.327121,"measured":0.514338},"p":0.514338,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.100998,"focused":0.497869,"open":0.401133},"p":0.497869,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.447601,"blunt":0.367722,"gentle":0.184676},"p":0.367722,"released":true},"formality":{"level":"formal","odds":{"casual":0.131218,"formal":0.432193,"neutral":0.436589},"p":0.432193,"released":true},"humour":{"level":"none","odds":{"light":0.202099,"none":0.753233,"playful":0.044668},"p":0.753233,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.393253,"lead":0.094923,"suggest":0.511825},"p":0.511825,"released":true},"verbosity":{"level":"terse","odds":{"full":0.152431,"short":0.40592,"terse":0.44165},"p":0.44165,"released":true},"warmth":{"level":"cool","odds":{"cool":0.586271,"neutral":0.358457,"warm":0.055272},"p":0.586271,"released":true}},"state_digest":"sha256:3e6d68af75a2f3a793b22b4487c6a0b0759d01bc188191cf1f52ef44b01df3b4","status":"ok","turn":0,"unsure":[],"why":"baseline (no live evidence)"} +{"agenda":["acknowledge","diagnose","fix","verify","report"],"engine":{"program":"sha256:05db197e4cb9afbfa93a4a665728348c89616e2680a3fe5ad2c296c4775531f7","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["no_bold_lead","report_after_verify"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"criticism":false,"error":false,"errors_in_row":0,"loss":false,"praise":false,"sentiment":"positive","stakes":0,"task":"question","time_pressure":0},"line":"Stance: 1-2 sentences; blunt: state the problem first; plain and businesslike; no jokes; propose the next step; formal and precise; stay on the task.","line_tokens":38,"mood":{"arousal":{"level":"steady","odds":{"calm":0.319458,"keyed":0.245112,"steady":0.43543},"p":0.43543,"released":true},"valence":{"level":"even","odds":{"down":0.236662,"even":0.647513,"up":0.115826},"p":0.647513,"released":true}},"persona":{"digest":"sha256:45919fc382359af2233989826b3c23c9c7257afbf3076098230564863a8a8ca6","name":"Rook","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"measured","odds":{"bold":0.158541,"careful":0.327121,"measured":0.514338},"p":0.514338,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.100998,"focused":0.497869,"open":0.401133},"p":0.497869,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.447601,"blunt":0.367722,"gentle":0.184676},"p":0.367722,"released":true},"formality":{"level":"formal","odds":{"casual":0.155132,"formal":0.400257,"neutral":0.444611},"p":0.400257,"released":true},"humour":{"level":"none","odds":{"light":0.273123,"none":0.629206,"playful":0.097671},"p":0.629206,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.393253,"lead":0.094923,"suggest":0.511825},"p":0.511825,"released":true},"verbosity":{"level":"terse","odds":{"full":0.152431,"short":0.40592,"terse":0.44165},"p":0.44165,"released":true},"warmth":{"level":"cool","odds":{"cool":0.563603,"neutral":0.373446,"warm":0.06295},"p":0.563603,"released":true}},"state_digest":"sha256:ea5eca3dea8e175b9e8d202cf52f7c3d96baa37241f932c6b7f3c71526f62829","status":"ok","turn":1,"unsure":[],"why":"user upbeat -> valence up, humour playful"} +{"agenda":["acknowledge","diagnose","fix","verify","report"],"engine":{"program":"sha256:b9c486cecb9ca796ee4c7a5f32f785e1b400574b65dd163a255b8bab25b0762f","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["no_bold_lead","report_after_verify"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"criticism":false,"error":false,"errors_in_row":0,"loss":false,"praise":false,"sentiment":"neutral","stakes":0.3,"task":"question","time_pressure":0},"line":"Stance: verify before claiming anything; 1-2 sentences; blunt: state the problem first; plain and businesslike; no jokes; propose the next step.","line_tokens":36,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.297597,"keyed":0.264448,"steady":0.437954},"p":0.264448,"released":true},"valence":{"level":"even","odds":{"down":0.30097,"even":0.624805,"up":0.074225},"p":0.624805,"released":true}},"persona":{"digest":"sha256:45919fc382359af2233989826b3c23c9c7257afbf3076098230564863a8a8ca6","name":"Rook","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0.086772,"careful":0.461291,"measured":0.451937},"p":0.461291,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.100998,"focused":0.497869,"open":0.401133},"p":0.497869,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.4484,"blunt":0.364218,"gentle":0.187382},"p":0.364218,"released":true},"formality":{"level":"formal","odds":{"casual":0.096842,"formal":0.496437,"neutral":0.40672},"p":0.496437,"released":true},"humour":{"level":"none","odds":{"light":0.161116,"none":0.812696,"playful":0.026188},"p":0.812696,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.398268,"lead":0.095339,"suggest":0.506393},"p":0.506393,"released":true},"verbosity":{"level":"terse","odds":{"full":0.164513,"short":0.412286,"terse":0.423202},"p":0.423202,"released":true},"warmth":{"level":"cool","odds":{"cool":0.595581,"neutral":0.352363,"warm":0.052056},"p":0.595581,"released":true}},"state_digest":"sha256:7e5bf7d3e642dadcc4e13e138b766826087e6b176089889a3053285bd73f2693","status":"ok","turn":2,"unsure":[],"why":"stakes 0.3 -> caution careful, humour none"} +{"agenda":["acknowledge","diagnose","fix","verify","report"],"engine":{"program":"sha256:fee373e37b20e469a91fd455f6e4343dc1088981a1594e8ceda5bea1050b758c","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["after_error_shorter","no_bold_lead","report_after_verify"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"criticism":false,"error":true,"errors_in_row":1,"loss":false,"praise":false,"sentiment":"neutral","stakes":0,"task":"question","time_pressure":0},"line":"Stance: shorter than last time, check more; verify before claiming anything; do only what was asked; 1-2 sentences; blunt: state the problem first.","line_tokens":37,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.065675,"keyed":0.583992,"steady":0.350332},"p":0.583992,"released":true},"valence":{"level":"even","odds":{"down":0.353738,"even":0.5871,"up":0.059162},"p":0.5871,"released":true}},"persona":{"digest":"sha256:45919fc382359af2233989826b3c23c9c7257afbf3076098230564863a8a8ca6","name":"Rook","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0,"careful":1,"measured":0},"p":1,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.100998,"focused":0.497869,"open":0.401133},"p":0.497869,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.424307,"blunt":0.450925,"gentle":0.124769},"p":0.450925,"released":true},"formality":{"level":"formal","odds":{"casual":0.130027,"formal":0.433899,"neutral":0.436074},"p":0.433899,"released":true},"humour":{"level":"none","odds":{"light":0.198785,"none":0.758283,"playful":0.042931},"p":0.758283,"released":true},"initiative":{"level":"follow","odds":{"follow":0.510525,"lead":0.042249,"suggest":0.447226},"p":0.510525,"released":true},"verbosity":{"level":"terse","odds":{"full":0,"short":0,"terse":1},"p":1,"released":true},"warmth":{"level":"cool","odds":{"cool":0.589756,"neutral":0.356054,"warm":0.05419},"p":0.589756,"released":true}},"state_digest":"sha256:8274c0997ff3fcad1c6f8cdf0fd6a9b5ae3a644ea2c1698c97931e9ebc5a8cb9","status":"ok","turn":3,"unsure":[],"why":"last action failed -> caution careful, verbosity terse; errors in a row 1 -> caution careful, verbosity terse"} +{"agenda":["acknowledge","diagnose","fix","verify","report"],"engine":{"program":"sha256:55da6cc364e46908b2670e8bd9323b1fa7a8421b56dac8db1acc99d41beee4fe","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["after_error_shorter","no_bold_lead","report_after_verify"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"criticism":false,"error":true,"errors_in_row":2,"loss":false,"praise":false,"sentiment":"negative","stakes":0,"task":"question","time_pressure":0},"line":"Stance: shorter than last time, check more; verify before claiming anything; do only what was asked; 1-2 sentences; plain and businesslike; no jokes.","line_tokens":38,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.037514,"keyed":0.677337,"steady":0.285149},"p":0.677337,"released":true},"valence":{"level":"down","odds":{"down":0.535046,"even":0.44466,"up":0.020295},"p":0.535046,"released":true}},"persona":{"digest":"sha256:45919fc382359af2233989826b3c23c9c7257afbf3076098230564863a8a8ca6","name":"Rook","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0,"careful":1,"measured":0},"p":1,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.100998,"focused":0.497869,"open":0.401133},"p":0.497869,"released":true},"directness":{"level":"balanced","odds":{"balanced":0.457829,"blunt":0.360445,"gentle":0.181726},"p":0.457829,"released":true},"formality":{"level":"formal","odds":{"casual":0.118291,"formal":0.447012,"neutral":0.434698},"p":0.447012,"released":true},"humour":{"level":"none","odds":{"light":0.093282,"none":0.89872,"playful":0.007998},"p":0.89872,"released":true},"initiative":{"level":"follow","odds":{"follow":0.510525,"lead":0.042249,"suggest":0.447226},"p":0.510525,"released":true},"verbosity":{"level":"terse","odds":{"full":0,"short":0,"terse":1},"p":1,"released":true},"warmth":{"level":"cool","odds":{"cool":0.51054,"neutral":0.408323,"warm":0.081136},"p":0.51054,"released":true}},"state_digest":"sha256:3e8088a4288b2d951347ab63a367e6f982cf5038dc6ae4690d2fa4ac949a007d","status":"ok","turn":4,"unsure":[],"why":"last action failed -> caution careful, verbosity terse; user upset -> valence down, humour none"} +{"agenda":["acknowledge","diagnose","fix","verify","report"],"engine":{"program":"sha256:28c9689841359917dadef24a84a574f80475995664b022a8f643f34e97c6f303","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["after_error_shorter","no_bold_lead","report_after_verify"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"criticism":true,"error":true,"errors_in_row":3,"loss":false,"praise":false,"sentiment":"negative","stakes":0,"task":"question","time_pressure":0},"line":"Stance: shorter than last time, check more; verify before claiming anything; do only what was asked; 1-2 sentences; plain and businesslike; no jokes.","line_tokens":38,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.021611,"keyed":0.750561,"steady":0.227828},"p":0.750561,"released":true},"valence":{"level":"down","odds":{"down":0.691814,"even":0.301069,"up":0.007117},"p":0.691814,"released":true}},"persona":{"digest":"sha256:45919fc382359af2233989826b3c23c9c7257afbf3076098230564863a8a8ca6","name":"Rook","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0,"careful":1,"measured":0},"p":1,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.100998,"focused":0.497869,"open":0.401133},"p":0.497869,"released":true},"directness":{"level":"balanced","odds":{"balanced":0.457829,"blunt":0.360445,"gentle":0.181726},"p":0.457829,"released":true},"formality":{"level":"formal","odds":{"casual":0.074949,"formal":0.542136,"neutral":0.382915},"p":0.542136,"released":true},"humour":{"level":"none","odds":{"light":0.075667,"none":0.919303,"playful":0.00503},"p":0.919303,"released":true},"initiative":{"level":"follow","odds":{"follow":0.510525,"lead":0.042249,"suggest":0.447226},"p":0.510525,"released":true},"verbosity":{"level":"terse","odds":{"full":0,"short":0,"terse":1},"p":1,"released":true},"warmth":{"level":"cool","odds":{"cool":0.548493,"neutral":0.385492,"warm":0.066015},"p":0.548493,"released":true}},"state_digest":"sha256:df10ff8ddbd638f6fbcbb0e0a382f2c909c888ca1bef7fd7d903a249f248bafc","status":"ok","turn":5,"unsure":[],"why":"errors in a row 3 -> caution careful, verbosity terse; last action failed -> caution careful, verbosity terse"} +{"agenda":["verify","acknowledge","diagnose","fix","report"],"engine":{"program":"sha256:08eeffc39adb01f206a1535f640f70935bb966550afc583810644388e70a7e10","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["verify_before_done","no_bold_lead","report_after_verify"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":true,"criticism":false,"error":false,"errors_in_row":0,"loss":false,"praise":false,"sentiment":"neutral","stakes":0,"task":"question","time_pressure":0},"line":"Stance: verify before saying done; verify before claiming anything; 1-2 sentences; blunt: state the problem first; plain and businesslike; no jokes.","line_tokens":37,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.101291,"keyed":0.521173,"steady":0.377537},"p":0.521173,"released":true},"valence":{"level":"down","odds":{"down":0.544258,"even":0.434665,"up":0.021077},"p":0.544258,"released":true}},"persona":{"digest":"sha256:45919fc382359af2233989826b3c23c9c7257afbf3076098230564863a8a8ca6","name":"Rook","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0,"careful":1,"measured":0},"p":1,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.100998,"focused":0.497869,"open":0.401133},"p":0.497869,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.44386,"blunt":0.383457,"gentle":0.172683},"p":0.383457,"released":true},"formality":{"level":"formal","odds":{"casual":0.121466,"formal":0.44639,"neutral":0.432144},"p":0.44639,"released":true},"humour":{"level":"none","odds":{"light":0.17365,"none":0.795198,"playful":0.031152},"p":0.795198,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.416498,"lead":0.068636,"suggest":0.514866},"p":0.514866,"released":true},"verbosity":{"level":"terse","odds":{"full":0.102954,"short":0.368287,"terse":0.528759},"p":0.528759,"released":true},"warmth":{"level":"cool","odds":{"cool":0.616068,"neutral":0.337569,"warm":0.046363},"p":0.616068,"released":true}},"state_digest":"sha256:52f49aa17522dd5bbd608a44f02648bd8a43370543432adc593fbce1a084ac83","status":"ok","turn":6,"unsure":[],"why":"about to report done -> caution careful"} +{"agenda":["acknowledge","diagnose","fix","verify","report"],"engine":{"program":"sha256:b44a8ed9bb1ce2c04e58c19ff4179d098bea32cc32be47bc37ba880438065369","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["no_bold_lead","report_after_verify"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"criticism":false,"error":false,"errors_in_row":0,"loss":false,"praise":true,"sentiment":"positive","stakes":0,"task":"question","time_pressure":0},"line":"Stance: 1-2 sentences; blunt: state the problem first; no jokes; propose the next step; stay on the task; order: acknowledge > diagnose > fix > verify > report.","line_tokens":40,"mood":{"arousal":{"level":"steady","odds":{"calm":0.243149,"keyed":0.32167,"steady":0.435181},"p":0.435181,"released":true},"valence":{"level":"even","odds":{"down":0.155685,"even":0.647161,"up":0.197154},"p":0.647161,"released":true}},"persona":{"digest":"sha256:45919fc382359af2233989826b3c23c9c7257afbf3076098230564863a8a8ca6","name":"Rook","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"measured","odds":{"bold":0.14556,"careful":0.347212,"measured":0.507228},"p":0.507228,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.100998,"focused":0.497869,"open":0.401133},"p":0.497869,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.446441,"blunt":0.372646,"gentle":0.180913},"p":0.372646,"released":true},"formality":{"level":"neutral","odds":{"casual":0.2411,"formal":0.298615,"neutral":0.460286},"p":0.460286,"released":true},"humour":{"level":"none","odds":{"light":0.347707,"none":0.404636,"playful":0.247657},"p":0.404636,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.394025,"lead":0.0953,"suggest":0.510675},"p":0.510675,"released":true},"verbosity":{"level":"terse","odds":{"full":0.136626,"short":0.394755,"terse":0.468618},"p":0.468618,"released":true},"warmth":{"level":"neutral","odds":{"cool":0.324609,"neutral":0.487527,"warm":0.187865},"p":0.487527,"released":true}},"state_digest":"sha256:0c548820bc40b409ca744d8a86ee3cbd1caadb06b4f72129f34f84d0dabaeadf","status":"ok","turn":7,"unsure":[],"why":"praise -> valence up, warmth warm; user upbeat -> valence up, humour playful"} +{"agenda":["acknowledge","diagnose","fix","verify","report"],"engine":{"program":"sha256:44d7f4b6a35779d9d3b1ef4eaa6d2e947c13d66d3fb4529a268fb76d74007ba5","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["no_bold_lead","report_after_verify"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"criticism":false,"error":false,"errors_in_row":0,"loss":false,"praise":false,"sentiment":"neutral","stakes":0,"task":"question","time_pressure":0.9},"line":"Stance: verify before claiming anything; 1-2 sentences; blunt: state the problem first; plain and businesslike; no jokes; propose the next step.","line_tokens":36,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.128586,"keyed":0.468838,"steady":0.402575},"p":0.468838,"released":true},"valence":{"level":"even","odds":{"down":0.293003,"even":0.631101,"up":0.075895},"p":0.631101,"released":true}},"persona":{"digest":"sha256:45919fc382359af2233989826b3c23c9c7257afbf3076098230564863a8a8ca6","name":"Rook","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0.119109,"careful":0.37442,"measured":0.506471},"p":0.37442,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.024636,"focused":0.734697,"open":0.240667},"p":0.734697,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.431135,"blunt":0.429652,"gentle":0.139214},"p":0.429652,"released":true},"formality":{"level":"formal","odds":{"casual":0.118108,"formal":0.450179,"neutral":0.431713},"p":0.450179,"released":true},"humour":{"level":"none","odds":{"light":0.139949,"none":0.840864,"playful":0.019187},"p":0.840864,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.29916,"lead":0.149003,"suggest":0.551837},"p":0.551837,"released":true},"verbosity":{"level":"terse","odds":{"full":0.011857,"short":0.155173,"terse":0.83297},"p":0.83297,"released":true},"warmth":{"level":"cool","odds":{"cool":0.58563,"neutral":0.359216,"warm":0.055153},"p":0.58563,"released":true}},"state_digest":"sha256:572ca877df6f0bf6da2661650fa857e3465cdbf773a7c04578a5ee2db54abb17","status":"ok","turn":8,"unsure":[],"why":"time pressure 0.9 -> verbosity terse, curiosity focused"} +{"agenda":["acknowledge","diagnose","fix","verify","report"],"engine":{"program":"sha256:58f60d3f9e6ba9ff5b6b561413846533d58d672fa6658c6cc33def596583b07f","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["high_stakes_careful","no_bold_lead","report_after_verify"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"criticism":false,"error":false,"errors_in_row":0,"loss":false,"praise":false,"sentiment":"neutral","stakes":0.8,"task":"question","time_pressure":0.9},"line":"Stance: high stakes, be careful; verify before claiming anything; 1-2 sentences; blunt: state the problem first; plain and businesslike; no jokes.","line_tokens":37,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.062388,"keyed":0.604355,"steady":0.333256},"p":0.604355,"released":true},"valence":{"level":"even","odds":{"down":0.347403,"even":0.601981,"up":0.050616},"p":0.601981,"released":true}},"persona":{"digest":"sha256:45919fc382359af2233989826b3c23c9c7257afbf3076098230564863a8a8ca6","name":"Rook","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0,"careful":1,"measured":0},"p":1,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.024636,"focused":0.734697,"open":0.240667},"p":0.734697,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.431781,"blunt":0.427545,"gentle":0.140673},"p":0.427545,"released":true},"formality":{"level":"formal","odds":{"casual":0.047511,"formal":0.618379,"neutral":0.33411},"p":0.618379,"released":true},"humour":{"level":"none","odds":{"light":0,"none":1,"playful":0},"p":1,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.321501,"lead":0.108846,"suggest":0.569653},"p":0.569653,"released":true},"verbosity":{"level":"terse","odds":{"full":0.013944,"short":0.16881,"terse":0.817246},"p":0.817246,"released":true},"warmth":{"level":"cool","odds":{"cool":0.625586,"neutral":0.331727,"warm":0.042687},"p":0.625586,"released":true}},"state_digest":"sha256:286e58ed1c7b1b61b77623192d5731e345fb5828bda793401a101a232c41283d","status":"ok","turn":9,"unsure":[],"why":"time pressure 0.9 -> verbosity terse, curiosity focused; stakes 0.8 -> caution careful, humour none"} +{"agenda":["acknowledge","diagnose","fix","verify","report"],"engine":{"program":"sha256:cd5d8e120a09d26dd1770679bbd1106c66ac0e50e149f9966803d75a4f63f06a","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["no_jokes_on_loss","no_bold_lead","report_after_verify"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"criticism":false,"error":false,"errors_in_row":0,"loss":true,"praise":false,"sentiment":"negative","stakes":0,"task":"question","time_pressure":0},"line":"Stance: no jokes; verify before claiming anything; 1-2 sentences; propose the next step; stay on the task.","line_tokens":27,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.216887,"keyed":0.350428,"steady":0.432685},"p":0.350428,"released":true},"valence":{"level":"down","odds":{"down":0.633388,"even":0.356641,"up":0.009972},"p":0.633388,"released":true}},"persona":{"digest":"sha256:45919fc382359af2233989826b3c23c9c7257afbf3076098230564863a8a8ca6","name":"Rook","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0.140929,"careful":0.354464,"measured":0.504607},"p":0.354464,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.100998,"focused":0.497869,"open":0.401133},"p":0.497869,"released":true},"directness":{"level":"balanced","odds":{"balanced":0.455367,"blunt":0.201946,"gentle":0.342687},"p":0.455367,"released":true},"formality":{"level":"neutral","odds":{"casual":0.131848,"formal":0.417199,"neutral":0.450953},"p":0.450953,"released":true},"humour":{"level":"none","odds":{"light":0,"none":1,"playful":0},"p":1,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.394303,"lead":0.095428,"suggest":0.510269},"p":0.510269,"released":true},"verbosity":{"level":"terse","odds":{"full":0.094147,"short":0.355654,"terse":0.550199},"p":0.550199,"released":true},"warmth":{"level":"neutral","odds":{"cool":0.300147,"neutral":0.497625,"warm":0.202228},"p":0.497625,"released":true}},"state_digest":"sha256:0b20a96cdfabf68bf371f8a029d7c8b935415db5e67d93fc0839f75d89329476","status":"ok","turn":10,"unsure":[],"why":"user reported a loss -> valence down, humour none; user upset -> valence down, humour none"} +{"agenda":["acknowledge","diagnose","fix","verify","report"],"engine":{"program":"sha256:9ae4a2a32757f8fb1e3b540d19b3ab9e2fd7e9ff9e37ca117cd87c64b5cd4688","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["no_bold_lead","report_after_verify"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"criticism":false,"error":false,"errors_in_row":0,"loss":false,"praise":false,"sentiment":"negative","stakes":0,"task":"question","time_pressure":0},"line":"Stance: 1-2 sentences; plain and businesslike; no jokes; propose the next step; formal and precise; stay on the task.","line_tokens":30,"mood":{"arousal":{"level":"steady","odds":{"calm":0.280853,"keyed":0.282011,"steady":0.437137},"p":0.437137,"released":true},"valence":{"level":"down","odds":{"down":0.647877,"even":0.342197,"up":0.009926},"p":0.647877,"released":true}},"persona":{"digest":"sha256:45919fc382359af2233989826b3c23c9c7257afbf3076098230564863a8a8ca6","name":"Rook","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"measured","odds":{"bold":0.152099,"careful":0.337028,"measured":0.510873},"p":0.510873,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.100998,"focused":0.497869,"open":0.401133},"p":0.497869,"released":true},"directness":{"level":"balanced","odds":{"balanced":0.461799,"blunt":0.283574,"gentle":0.254627},"p":0.461799,"released":true},"formality":{"level":"formal","odds":{"casual":0.115257,"formal":0.451808,"neutral":0.432935},"p":0.451808,"released":true},"humour":{"level":"none","odds":{"light":0.084684,"none":0.908851,"playful":0.006465},"p":0.908851,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.393634,"lead":0.095114,"suggest":0.511252},"p":0.511252,"released":true},"verbosity":{"level":"terse","odds":{"full":0.155629,"short":0.407042,"terse":0.437329},"p":0.437329,"released":true},"warmth":{"level":"cool","odds":{"cool":0.52523,"neutral":0.399661,"warm":0.075108},"p":0.52523,"released":true}},"state_digest":"sha256:7ea6176682f63e1c566ee00e988a686f0c919d4dbc751b71d4f8b16ca408511f","status":"ok","turn":11,"unsure":[],"why":"user upset -> valence down, humour none"} +{"agenda":["acknowledge","diagnose","fix","verify","report"],"engine":{"program":"sha256:79434867f9ac10841d3f894ac1df61477dc11b30c2a51a1244a10272e35f025c","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["no_bold_lead","report_after_verify"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"criticism":false,"error":false,"errors_in_row":0,"loss":false,"praise":false,"sentiment":"neutral","stakes":0,"task":"question","time_pressure":0},"line":"Stance: 1-2 sentences; blunt: state the problem first; plain and businesslike; no jokes; propose the next step; formal and precise; stay on the task.","line_tokens":38,"mood":{"arousal":{"level":"steady","odds":{"calm":0.297272,"keyed":0.265539,"steady":0.43719},"p":0.43719,"released":true},"valence":{"level":"down","odds":{"down":0.516563,"even":0.458706,"up":0.024731},"p":0.516563,"released":true}},"persona":{"digest":"sha256:45919fc382359af2233989826b3c23c9c7257afbf3076098230564863a8a8ca6","name":"Rook","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"measured","odds":{"bold":0.154892,"careful":0.332706,"measured":0.512403},"p":0.512403,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.100998,"focused":0.497869,"open":0.401133},"p":0.497869,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.447277,"blunt":0.369101,"gentle":0.183622},"p":0.369101,"released":true},"formality":{"level":"formal","odds":{"casual":0.122592,"formal":0.444725,"neutral":0.432682},"p":0.444725,"released":true},"humour":{"level":"none","odds":{"light":0.17708,"none":0.790284,"playful":0.032637},"p":0.790284,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.393468,"lead":0.095033,"suggest":0.511499},"p":0.511499,"released":true},"verbosity":{"level":"terse","odds":{"full":0.147973,"short":0.402857,"terse":0.44917},"p":0.44917,"released":true},"warmth":{"level":"cool","odds":{"cool":0.612489,"neutral":0.340113,"warm":0.047397},"p":0.612489,"released":true}},"state_digest":"sha256:6d7813ebc0f9edd5b66126f36b22ef5bb438fe6db701d8b17ba1d4223af4dbd4","status":"ok","turn":12,"unsure":[],"why":"baseline (no live evidence)"} +{"agenda":["verify","acknowledge","diagnose","fix","report"],"engine":{"program":"sha256:1590f042de74cee87ee8b08b4444a50e323f4b786dfd56f8264c0afdd1c96778","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["verify_before_done","high_stakes_careful","no_bold_lead","report_after_verify"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":true,"criticism":false,"error":false,"errors_in_row":0,"loss":false,"praise":false,"sentiment":"neutral","stakes":0.9,"task":"question","time_pressure":0},"line":"Stance: verify before saying done; high stakes, be careful; a short paragraph; verify before claiming anything; plain and businesslike; no jokes.","line_tokens":37,"mood":{"arousal":{"level":"steady","odds":{"calm":0.21469,"keyed":0.342619,"steady":0.44269},"p":0.44269,"released":true},"valence":{"level":"down","odds":{"down":0.495873,"even":0.481452,"up":0.022675},"p":0.495873,"released":true}},"persona":{"digest":"sha256:45919fc382359af2233989826b3c23c9c7257afbf3076098230564863a8a8ca6","name":"Rook","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0,"careful":1,"measured":0},"p":1,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.100998,"focused":0.497869,"open":0.401133},"p":0.497869,"released":true},"directness":{"level":"balanced","odds":{"balanced":0.449401,"blunt":0.359793,"gentle":0.190806},"p":0.449401,"released":true},"formality":{"level":"formal","odds":{"casual":0.042328,"formal":0.637096,"neutral":0.320576},"p":0.637096,"released":true},"humour":{"level":"none","odds":{"light":0,"none":1,"playful":0},"p":1,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.416498,"lead":0.068636,"suggest":0.514866},"p":0.514866,"released":true},"verbosity":{"level":"short","odds":{"full":0.180035,"short":0.419823,"terse":0.400142},"p":0.419823,"released":true},"warmth":{"level":"cool","odds":{"cool":0.646176,"neutral":0.316364,"warm":0.037461},"p":0.646176,"released":true}},"state_digest":"sha256:81efb2b3efee6b96a8c26a866a4ff7b392c7620765aa8428d8ffc50f9d269de3","status":"ok","turn":13,"unsure":[],"why":"stakes 0.9 -> caution careful, humour none; about to report done -> caution careful"} +{"agenda":["acknowledge","diagnose","fix","verify","report"],"engine":{"program":"sha256:7ba83f71e37eadde7b031c7319a7cab5a134e775d3a4b972adeff37c45b2ebd6","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["no_bold_lead","report_after_verify"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"criticism":false,"error":false,"errors_in_row":0,"loss":false,"praise":true,"sentiment":"neutral","stakes":0,"task":"question","time_pressure":0},"line":"Stance: 1-2 sentences; blunt: state the problem first; no jokes; propose the next step; stay on the task; order: acknowledge > diagnose > fix > verify > report.","line_tokens":40,"mood":{"arousal":{"level":"steady","odds":{"calm":0.313856,"keyed":0.250142,"steady":0.436001},"p":0.436001,"released":true},"valence":{"level":"even","odds":{"down":0.197538,"even":0.652664,"up":0.149798},"p":0.652664,"released":true}},"persona":{"digest":"sha256:45919fc382359af2233989826b3c23c9c7257afbf3076098230564863a8a8ca6","name":"Rook","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"measured","odds":{"bold":0.157628,"careful":0.328513,"measured":0.513858},"p":0.513858,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.100998,"focused":0.497869,"open":0.401133},"p":0.497869,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.44752,"blunt":0.368067,"gentle":0.184413},"p":0.368067,"released":true},"formality":{"level":"neutral","odds":{"casual":0.204039,"formal":0.338191,"neutral":0.457769},"p":0.457769,"released":true},"humour":{"level":"none","odds":{"light":0.308541,"none":0.547309,"playful":0.14415},"p":0.547309,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.393306,"lead":0.094951,"suggest":0.511743},"p":0.511743,"released":true},"verbosity":{"level":"terse","odds":{"full":0.151315,"short":0.405159,"terse":0.443526},"p":0.443526,"released":true},"warmth":{"level":"neutral","odds":{"cool":0.343635,"neutral":0.483048,"warm":0.173318},"p":0.483048,"released":true}},"state_digest":"sha256:c7cd9ef26b532bc65732faa1293853415af878c5849c569968e1e661f70e7760","status":"ok","turn":14,"unsure":[],"why":"praise -> valence up, warmth warm"} +{"agenda":["acknowledge","diagnose","fix","verify","report"],"engine":{"program":"sha256:f0c7aef1ceaa1ae53b3463d36150e356e9e2639a735873efd01b3f2481dbe248","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["no_bold_lead","report_after_verify"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"criticism":false,"error":false,"errors_in_row":0,"loss":false,"praise":false,"sentiment":"positive","stakes":0,"task":"question","time_pressure":0},"line":"Stance: 1-2 sentences; blunt: state the problem first; plain and businesslike; no jokes; propose the next step; formal and precise; stay on the task.","line_tokens":38,"mood":{"arousal":{"level":"steady","odds":{"calm":0.318961,"keyed":0.245555,"steady":0.435484},"p":0.435484,"released":true},"valence":{"level":"even","odds":{"down":0.219885,"even":0.653238,"up":0.126877},"p":0.653238,"released":true}},"persona":{"digest":"sha256:45919fc382359af2233989826b3c23c9c7257afbf3076098230564863a8a8ca6","name":"Rook","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"measured","odds":{"bold":0.15846,"careful":0.327244,"measured":0.514296},"p":0.514296,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.100998,"focused":0.497869,"open":0.401133},"p":0.497869,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.447594,"blunt":0.367753,"gentle":0.184653},"p":0.367753,"released":true},"formality":{"level":"formal","odds":{"casual":0.156701,"formal":0.398195,"neutral":0.445105},"p":0.398195,"released":true},"humour":{"level":"none","odds":{"light":0.276128,"none":0.623035,"playful":0.100837},"p":0.623035,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.393258,"lead":0.094925,"suggest":0.511817},"p":0.511817,"released":true},"verbosity":{"level":"terse","odds":{"full":0.152332,"short":0.405853,"terse":0.441815},"p":0.441815,"released":true},"warmth":{"level":"cool","odds":{"cool":0.560179,"neutral":0.375699,"warm":0.064121},"p":0.560179,"released":true}},"state_digest":"sha256:c2774987cf10b98a608b2ed55dedbbcbb49b96eaeabbcad404a08cc4a7c1cf62","status":"ok","turn":15,"unsure":[],"why":"user upbeat -> valence up, humour playful"} +{"agenda":["acknowledge","diagnose","fix","verify","report"],"engine":{"program":"sha256:6035aac7ec05e93c225baf3d11086d4d074a1f02a34325c367ff85ec92be30fd","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["no_bold_lead","report_after_verify"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"criticism":true,"error":false,"errors_in_row":0,"loss":false,"praise":false,"sentiment":"neutral","stakes":0,"task":"question","time_pressure":0},"line":"Stance: verify before claiming anything; 1-2 sentences; blunt: state the problem first; plain and businesslike; no jokes; propose the next step.","line_tokens":36,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.288583,"keyed":0.272432,"steady":0.438984},"p":0.272432,"released":true},"valence":{"level":"even","odds":{"down":0.336821,"even":0.599526,"up":0.063653},"p":0.599526,"released":true}},"persona":{"digest":"sha256:45919fc382359af2233989826b3c23c9c7257afbf3076098230564863a8a8ca6","name":"Rook","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0.083604,"careful":0.469119,"measured":0.447276},"p":0.469119,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.100998,"focused":0.497869,"open":0.401133},"p":0.497869,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.447159,"blunt":0.369609,"gentle":0.183233},"p":0.369609,"released":true},"formality":{"level":"formal","odds":{"casual":0.087024,"formal":0.522174,"neutral":0.390801},"p":0.522174,"released":true},"humour":{"level":"none","odds":{"light":0.190381,"none":0.77127,"playful":0.038349},"p":0.77127,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.398551,"lead":0.09522,"suggest":0.50623},"p":0.50623,"released":true},"verbosity":{"level":"terse","odds":{"full":0.146311,"short":0.401768,"terse":0.451922},"p":0.451922,"released":true},"warmth":{"level":"cool","odds":{"cool":0.60498,"neutral":0.345695,"warm":0.049325},"p":0.60498,"released":true}},"state_digest":"sha256:08e2890ac868a424a3d25d66b7bafdd28a13f750458c503fe228553286661fd8","status":"ok","turn":16,"unsure":[],"why":"criticism -> valence down, caution careful"} +{"agenda":["acknowledge","diagnose","fix","verify","report"],"engine":{"program":"sha256:89ade5211d27a09745dc47e1266669621fc13638236517c513f093ca526aba69","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["after_error_shorter","no_bold_lead","report_after_verify"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"criticism":false,"error":true,"errors_in_row":1,"loss":false,"praise":false,"sentiment":"neutral","stakes":0,"task":"question","time_pressure":0.5},"line":"Stance: shorter than last time, check more; unsure about initiative: keep it suggest; verify before claiming anything; 1-2 sentences.","line_tokens":34,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.048089,"keyed":0.638462,"steady":0.313449},"p":0.638462,"released":true},"valence":{"level":"even","odds":{"down":0.395848,"even":0.558618,"up":0.045534},"p":0.558618,"released":true}},"persona":{"digest":"sha256:45919fc382359af2233989826b3c23c9c7257afbf3076098230564863a8a8ca6","name":"Rook","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0,"careful":1,"measured":0},"p":1,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.047737,"focused":0.639668,"open":0.312594},"p":0.639668,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.424307,"blunt":0.450925,"gentle":0.124769},"p":0.450925,"released":true},"formality":{"level":"formal","odds":{"casual":0.119532,"formal":0.448619,"neutral":0.431849},"p":0.448619,"released":true},"humour":{"level":"none","odds":{"light":0.15489,"none":0.821035,"playful":0.024075},"p":0.821035,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.455909,"lead":0.056285,"suggest":0.487806},"p":0.487806,"released":true},"verbosity":{"level":"terse","odds":{"full":0,"short":0,"terse":1},"p":1,"released":true},"warmth":{"level":"cool","odds":{"cool":0.598939,"neutral":0.349823,"warm":0.051238},"p":0.598939,"released":true}},"state_digest":"sha256:5a20f2fce9295753c26a58684857ae7720312dfc18e929dcc1f71157c5f09702","status":"ok","turn":17,"unsure":["initiative"],"why":"last action failed -> caution careful, verbosity terse; time pressure 0.5 -> verbosity terse, curiosity focused"} +{"agenda":["acknowledge","diagnose","fix","verify","report"],"engine":{"program":"sha256:eb2888a41ed92e41c754cfef2cbf59d4714cc1c7364e6aacb5e62abe4294c1b1","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["no_bold_lead","report_after_verify"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"criticism":false,"error":false,"errors_in_row":0,"loss":false,"praise":false,"sentiment":"neutral","stakes":0.5,"task":"question","time_pressure":0},"line":"Stance: verify before claiming anything; 1-2 sentences; blunt: state the problem first; plain and businesslike; no jokes; propose the next step.","line_tokens":36,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.205716,"keyed":0.364875,"steady":0.42941},"p":0.364875,"released":true},"valence":{"level":"even","odds":{"down":0.386278,"even":0.567545,"up":0.046178},"p":0.567545,"released":true}},"persona":{"digest":"sha256:45919fc382359af2233989826b3c23c9c7257afbf3076098230564863a8a8ca6","name":"Rook","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0.048834,"careful":0.573602,"measured":0.377564},"p":0.573602,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.100998,"focused":0.497869,"open":0.401133},"p":0.497869,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.447677,"blunt":0.367366,"gentle":0.184957},"p":0.367366,"released":true},"formality":{"level":"formal","odds":{"casual":0.075883,"formal":0.542974,"neutral":0.381143},"p":0.542974,"released":true},"humour":{"level":"none","odds":{"light":0.12481,"none":0.860414,"playful":0.014775},"p":0.860414,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.402256,"lead":0.092569,"suggest":0.505175},"p":0.505175,"released":true},"verbosity":{"level":"terse","odds":{"full":0.153836,"short":0.406236,"terse":0.439928},"p":0.439928,"released":true},"warmth":{"level":"cool","odds":{"cool":0.616121,"neutral":0.337974,"warm":0.045905},"p":0.616121,"released":true}},"state_digest":"sha256:ab5f29e530aadfdc79e850f6ee4cc53a50c7ab55a0bb0c89d7f365fd1f4f7279","status":"ok","turn":18,"unsure":[],"why":"stakes 0.5 -> caution careful, humour none"} +{"agenda":["verify","acknowledge","diagnose","fix","report"],"engine":{"program":"sha256:6a927aecbad42330467be6c2f03ca4d604eef83852a0c2a04859ecc4e905a2dc","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["verify_before_done","no_bold_lead","report_after_verify"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":true,"criticism":false,"error":false,"errors_in_row":0,"loss":false,"praise":true,"sentiment":"positive","stakes":0,"task":"question","time_pressure":0},"line":"Stance: verify before saying done; verify before claiming anything; 1-2 sentences; blunt: state the problem first; no jokes; propose the next step.","line_tokens":37,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.170545,"keyed":0.400183,"steady":0.429272},"p":0.400183,"released":true},"valence":{"level":"even","odds":{"down":0.097505,"even":0.616667,"up":0.285828},"p":0.616667,"released":true}},"persona":{"digest":"sha256:45919fc382359af2233989826b3c23c9c7257afbf3076098230564863a8a8ca6","name":"Rook","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0,"careful":1,"measured":0},"p":1,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.100998,"focused":0.497869,"open":0.401133},"p":0.497869,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.445295,"blunt":0.377495,"gentle":0.17721},"p":0.377495,"released":true},"formality":{"level":"neutral","odds":{"casual":0.254144,"formal":0.285096,"neutral":0.460761},"p":0.460761,"released":true},"humour":{"level":"none","odds":{"light":0.352272,"none":0.368292,"playful":0.279436},"p":0.368292,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.416498,"lead":0.068636,"suggest":0.514866},"p":0.514866,"released":true},"verbosity":{"level":"terse","odds":{"full":0.121163,"short":0.383566,"terse":0.49527},"p":0.49527,"released":true},"warmth":{"level":"neutral","odds":{"cool":0.306358,"neutral":0.491411,"warm":0.202231},"p":0.491411,"released":true}},"state_digest":"sha256:b382d647aaebc5a8bbca5089b0fd0ba5cf69cccd239f5732664675d73fcb4076","status":"ok","turn":19,"unsure":[],"why":"praise -> valence up, warmth warm; user upbeat -> valence up, humour playful"} diff --git a/examples/persona/golden/trader-assistant/final-state.json b/examples/persona/golden/trader-assistant/final-state.json new file mode 100644 index 0000000..aab07e9 --- /dev/null +++ b/examples/persona/golden/trader-assistant/final-state.json @@ -0,0 +1 @@ +{"digest":"sha256:cbb97d951a91869ac47fcba6908910ad81857d7fe80488f6c1e2a11070696a55","genes":{"react":{"error":1.331},"shift":{"arousal":-0.4437,"caution":0.1488,"curiosity":0.5255,"directness":0.1459,"formality":0.1789,"humour":0.2964,"initiative":-0.1543,"valence":-0.2437,"verbosity":0.5736,"warmth":0.0637}},"history":{"error":[false,false,true,false,false],"loss":[false,false,false,false,false]},"mood":{"arousal":[-0.162347,0,0.162347],"valence":[-0.272731,0,0.272731]},"persona":{"digest":"sha256:c548ec292737d437be0fb6a18af6630ee4545064d35a843b34e9c27b9fc6120f","name":"Tick","version":"1.0.0"},"prev":{"agenda":["number","risk","idea"],"arousal":"keyed","caution":"careful","curiosity":"focused","directness":"blunt","formality":"neutral","humour":"none","initiative":"follow","valence":"even","verbosity":"terse","warmth":"neutral"},"probbit_persona_state":1,"rest":{"arousal":"keyed","caution":"careful","curiosity":"focused","directness":"blunt","formality":"neutral","humour":"none","initiative":"follow","valence":"even","verbosity":"terse","warmth":"cool"},"seed":1,"turn":20} diff --git a/examples/persona/golden/trader-assistant/lint.json b/examples/persona/golden/trader-assistant/lint.json new file mode 100644 index 0000000..e5291b2 --- /dev/null +++ b/examples/persona/golden/trader-assistant/lint.json @@ -0,0 +1 @@ +{"conflicts":[]} diff --git a/examples/persona/golden/trader-assistant/program-turn0.json b/examples/persona/golden/trader-assistant/program-turn0.json new file mode 100644 index 0000000..18e575f --- /dev/null +++ b/examples/persona/golden/trader-assistant/program-turn0.json @@ -0,0 +1 @@ +{"probbit_ir":1,"comment":"probbit persona Tick 1.0.0 seed 1 turn 0; active habits: never_lead, risk_before_idea","values":["l0","l1","l2","s0","s1","s2"],"vars":[{"id":"warmth","allowed":["l0","l1","l2"],"h":{"l0":-0.661537,"l1":-0.916291,"l2":-2.932032}},{"id":"directness","allowed":["l0","l1","l2"],"h":{"l0":-3.141632,"l1":-1.386294,"l2":-0.210775}},{"id":"verbosity","allowed":["l0","l1","l2"],"h":{"l0":-0.930275,"l1":-1.386294,"l2":-2.422132}},{"id":"humour","allowed":["l0","l1","l2"],"h":{"l0":-0.458919,"l1":-2.040221,"l2":-3.615623}},{"id":"formality","allowed":["l0","l1","l2"],"h":{"l0":-1.382873,"l1":-0.693147,"l2":-1.430538}},{"id":"initiative","allowed":["l0","l1","l2"],"h":{"l0":-0.356526,"l1":-1.049822,"l2":-3.150032}},{"id":"caution","allowed":["l0","l1","l2"],"h":{"l0":-2.451385,"l1":-0.693147,"l2":-0.767491}},{"id":"curiosity","allowed":["l0","l1","l2"],"h":{"l0":-0.813182,"l1":-1.609438,"l2":-2.470232}},{"id":"valence","allowed":["l0","l1","l2"],"h":{"l0":-1.65342,"l1":-0.356675,"l2":-2.14082}},{"id":"arousal","allowed":["l0","l1","l2"],"h":{"l0":-0.472591,"l1":-0.916291,"l2":-2.053138}},{"id":"step.number","allowed":["s0","s1","s2"],"h":{"s0":1,"s2":-1}},{"id":"step.risk","allowed":["s0","s1","s2"],"h":{}},{"id":"step.idea","allowed":["s0","s1","s2"],"h":{"s0":-0.6,"s2":0.6}},{"id":"free.warmth","allowed":["l0","l1","l2"],"h":{"l0":-0.661537,"l1":-0.916291,"l2":-2.932032}},{"id":"free.directness","allowed":["l0","l1","l2"],"h":{"l0":-3.141632,"l1":-1.386294,"l2":-0.210775}},{"id":"free.verbosity","allowed":["l0","l1","l2"],"h":{"l0":-0.930275,"l1":-1.386294,"l2":-2.422132}},{"id":"free.humour","allowed":["l0","l1","l2"],"h":{"l0":-0.458919,"l1":-2.040221,"l2":-3.615623}},{"id":"free.formality","allowed":["l0","l1","l2"],"h":{"l0":-1.382873,"l1":-0.693147,"l2":-1.430538}},{"id":"free.initiative","allowed":["l0","l1","l2"],"h":{"l0":-0.356526,"l1":-1.049822,"l2":-3.150032}},{"id":"free.caution","allowed":["l0","l1","l2"],"h":{"l0":-2.451385,"l1":-0.693147,"l2":-0.767491}},{"id":"free.curiosity","allowed":["l0","l1","l2"],"h":{"l0":-0.813182,"l1":-1.609438,"l2":-2.470232}},{"id":"free.valence","allowed":["l0","l1","l2"],"h":{"l0":-1.65342,"l1":-0.356675,"l2":-2.14082}},{"id":"free.arousal","allowed":["l0","l1","l2"],"h":{"l0":-0.472591,"l1":-0.916291,"l2":-2.053138}},{"id":"free.step.number","allowed":["s0","s1","s2"],"h":{"s0":1,"s2":-1}},{"id":"free.step.risk","allowed":["s0","s1","s2"],"h":{}},{"id":"free.step.idea","allowed":["s0","s1","s2"],"h":{"s0":-0.6,"s2":0.6}}],"pairs":[{"i":"valence","j":"humour","table":[[0.6,0,-0.6,0,0,0],[0,0,0,0,0,0],[-0.6,0,0.6,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]]},{"i":"arousal","j":"verbosity","table":[[-0.8,0,0.8,0,0,0],[0,0,0,0,0,0],[0.8,0,-0.8,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]]},{"i":"arousal","j":"caution","table":[[0.6,0,-0.6,0,0,0],[0,0,0,0,0,0],[-0.6,0,0.6,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]]},{"i":"directness","j":"verbosity","table":[[-0.5,0,0.5,0,0,0],[0,0,0,0,0,0],[0.5,0,-0.5,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]]},{"i":"valence","j":"warmth","table":[[0.3,0,-0.3,0,0,0],[0,0,0,0,0,0],[-0.3,0,0.3,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]]},{"i":"caution","j":"initiative","table":[[0,0,0.3,0,0,0],[0,0,0,0,0,0],[0,0,-1,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]]},{"i":"free.valence","j":"free.humour","table":[[0.6,0,-0.6,0,0,0],[0,0,0,0,0,0],[-0.6,0,0.6,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]]},{"i":"free.arousal","j":"free.verbosity","table":[[-0.8,0,0.8,0,0,0],[0,0,0,0,0,0],[0.8,0,-0.8,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]]},{"i":"free.arousal","j":"free.caution","table":[[0.6,0,-0.6,0,0,0],[0,0,0,0,0,0],[-0.6,0,0.6,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]]},{"i":"free.directness","j":"free.verbosity","table":[[-0.5,0,0.5,0,0,0],[0,0,0,0,0,0],[0.5,0,-0.5,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]]},{"i":"free.valence","j":"free.warmth","table":[[0.3,0,-0.3,0,0,0],[0,0,0,0,0,0],[-0.3,0,0.3,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]]},{"i":"free.caution","j":"free.initiative","table":[[0,0,0.3,0,0,0],[0,0,0,0,0,0],[0,0,-1,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]]}],"tables":[{"vars":["initiative"],"allow":[["l0"],["l1"]]}],"precedes":[{"before":"step.risk","after":"step.idea","gap":1}],"all_different":[{"vars":["step.number","step.risk","step.idea"]},{"vars":["free.step.number","free.step.risk","free.step.idea"]}]} diff --git a/examples/persona/golden/trader-assistant/state0.json b/examples/persona/golden/trader-assistant/state0.json new file mode 100644 index 0000000..a502b95 --- /dev/null +++ b/examples/persona/golden/trader-assistant/state0.json @@ -0,0 +1 @@ +{"digest":"sha256:42834fb9692638ebd305b096612c09e8dc33131b5b59f43e209ca5cc1761e57b","genes":{"react":{"error":1.331},"shift":{"arousal":-0.4437,"caution":0.1488,"curiosity":0.5255,"directness":0.1459,"formality":0.1789,"humour":0.2964,"initiative":-0.1543,"valence":-0.2437,"verbosity":0.5736,"warmth":0.0637}},"history":{"error":[],"loss":[]},"mood":{"arousal":[0,0,0],"valence":[0,0,0]},"persona":{"digest":"sha256:c548ec292737d437be0fb6a18af6630ee4545064d35a843b34e9c27b9fc6120f","name":"Tick","version":"1.0.0"},"prev":{"agenda":["number","risk","idea"],"arousal":"calm","caution":"measured","curiosity":"focused","directness":"blunt","formality":"neutral","humour":"none","initiative":"follow","valence":"even","verbosity":"terse","warmth":"cool"},"probbit_persona_state":1,"rest":{"arousal":"keyed","caution":"careful","curiosity":"focused","directness":"blunt","formality":"neutral","humour":"none","initiative":"follow","valence":"even","verbosity":"terse","warmth":"cool"},"seed":1,"turn":0} diff --git a/examples/persona/golden/trader-assistant/workday.jsonl b/examples/persona/golden/trader-assistant/workday.jsonl new file mode 100644 index 0000000..edc1aa5 --- /dev/null +++ b/examples/persona/golden/trader-assistant/workday.jsonl @@ -0,0 +1,20 @@ +{"agenda":["number","risk","idea"],"engine":{"program":"sha256:ea9bdb7255a61601f72319f47e2378da0a5a096812492338e5d1761eb843c383","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["never_lead","risk_before_idea"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"criticism":false,"error":false,"errors_in_row":0,"loss":false,"losses_recent":0,"praise":false,"sentiment":"neutral","stakes":0,"time_pressure":0},"line":"Stance: one line; state the risk and size; blunt: number first; no pleasantries; no jokes; answer only; stay on the position; order: number > risk > idea.","line_tokens":39,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.362598,"keyed":0.272208,"steady":0.365195},"p":0.272208,"released":true},"valence":{"level":"even","odds":{"down":0.323999,"even":0.6153,"up":0.060701},"p":0.6153,"released":true}},"persona":{"digest":"sha256:c548ec292737d437be0fb6a18af6630ee4545064d35a843b34e9c27b9fc6120f","name":"Tick","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0.101648,"careful":0.427964,"measured":0.470388},"p":0.427964,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.116159,"focused":0.609119,"open":0.274721},"p":0.609119,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.192626,"blunt":0.77656,"gentle":0.030814},"p":0.77656,"released":true},"formality":{"level":"neutral","odds":{"casual":0.253381,"formal":0.241587,"neutral":0.505032},"p":0.505032,"released":true},"humour":{"level":"none","odds":{"light":0.148897,"none":0.820774,"playful":0.03033},"p":0.820774,"released":true},"initiative":{"level":"follow","odds":{"follow":0.6667,"lead":0,"suggest":0.3333},"p":0.6667,"released":true},"verbosity":{"level":"terse","odds":{"full":0.112481,"short":0.284502,"terse":0.603017},"p":0.603017,"released":true},"warmth":{"level":"cool","odds":{"cool":0.553402,"neutral":0.396402,"warm":0.050195},"p":0.553402,"released":true}},"state_digest":"sha256:7a4e966d24faddc8f25d54eaffc9bc6cc433774df7672d7d95af8e2cdee34d13","status":"ok","turn":0,"unsure":[],"why":"baseline (no live evidence)"} +{"agenda":["number","risk","idea"],"engine":{"program":"sha256:ef001418f62f4417d9b2c558962e078a7b864c7687f37f46c49c588bc5efb214","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["never_lead","risk_before_idea"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"criticism":false,"error":false,"errors_in_row":0,"loss":false,"losses_recent":0,"praise":false,"sentiment":"positive","stakes":0,"time_pressure":0},"line":"Stance: one line; state the risk and size; blunt: number first; no pleasantries; no jokes; answer only; stay on the position; order: number > risk > idea.","line_tokens":39,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.362598,"keyed":0.272208,"steady":0.365195},"p":0.272208,"released":true},"valence":{"level":"even","odds":{"down":0.273043,"even":0.645305,"up":0.081652},"p":0.645305,"released":true}},"persona":{"digest":"sha256:c548ec292737d437be0fb6a18af6630ee4545064d35a843b34e9c27b9fc6120f","name":"Tick","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0.101648,"careful":0.427964,"measured":0.470388},"p":0.427964,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.116159,"focused":0.609119,"open":0.274721},"p":0.609119,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.192626,"blunt":0.77656,"gentle":0.030814},"p":0.77656,"released":true},"formality":{"level":"neutral","odds":{"casual":0.253381,"formal":0.241587,"neutral":0.505032},"p":0.505032,"released":true},"humour":{"level":"none","odds":{"light":0.191034,"none":0.754687,"playful":0.054279},"p":0.754687,"released":true},"initiative":{"level":"follow","odds":{"follow":0.6667,"lead":0,"suggest":0.3333},"p":0.6667,"released":true},"verbosity":{"level":"terse","odds":{"full":0.112481,"short":0.284502,"terse":0.603017},"p":0.603017,"released":true},"warmth":{"level":"cool","odds":{"cool":0.547508,"neutral":0.400653,"warm":0.051838},"p":0.547508,"released":true}},"state_digest":"sha256:1da807295e6ebae1d4c32a37dfd6aa1207b25dd80f239ae8f4337898859fae7d","status":"ok","turn":1,"unsure":[],"why":"trader upbeat -> valence up, humour playful"} +{"agenda":["number","risk","idea"],"engine":{"program":"sha256:796e422e847b0592fc8a8d49834657e416c4f0ee657adfdc05eecc2c0d1d1e29","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["never_lead","risk_before_idea"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"criticism":false,"error":false,"errors_in_row":0,"loss":false,"losses_recent":0,"praise":false,"sentiment":"neutral","stakes":0.3,"time_pressure":0},"line":"Stance: one line; state the risk and size; blunt: number first; no pleasantries; no jokes; answer only; stay on the position; order: number > risk > idea.","line_tokens":39,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.337423,"keyed":0.294937,"steady":0.36764},"p":0.294937,"released":true},"valence":{"level":"even","odds":{"down":0.302674,"even":0.63091,"up":0.066416},"p":0.63091,"released":true}},"persona":{"digest":"sha256:c548ec292737d437be0fb6a18af6630ee4545064d35a843b34e9c27b9fc6120f","name":"Tick","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0.047831,"careful":0.577145,"measured":0.375024},"p":0.577145,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.116159,"focused":0.609119,"open":0.274721},"p":0.609119,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.195194,"blunt":0.77288,"gentle":0.031926},"p":0.77288,"released":true},"formality":{"level":"neutral","odds":{"casual":0.253381,"formal":0.241587,"neutral":0.505032},"p":0.505032,"released":true},"humour":{"level":"none","odds":{"light":0.112801,"none":0.870796,"playful":0.016403},"p":0.870796,"released":true},"initiative":{"level":"follow","odds":{"follow":0.6667,"lead":0,"suggest":0.3333},"p":0.6667,"released":true},"verbosity":{"level":"terse","odds":{"full":0.125298,"short":0.293071,"terse":0.581631},"p":0.581631,"released":true},"warmth":{"level":"cool","odds":{"cool":0.551195,"neutral":0.398012,"warm":0.050794},"p":0.551195,"released":true}},"state_digest":"sha256:089ebe3d618d36d73ff42c7f16f252c7792d21112fb081efcd6fa2743ef996c4","status":"ok","turn":2,"unsure":[],"why":"size at risk 0.3 -> caution careful, humour none"} +{"agenda":["number","risk","idea"],"engine":{"program":"sha256:12bb674bb141373e354795c44da01aefbb37e4dd611885778a25cba23ea44b6e","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["never_lead","risk_before_idea"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"criticism":false,"error":true,"errors_in_row":1,"loss":false,"losses_recent":0,"praise":false,"sentiment":"neutral","stakes":0,"time_pressure":0},"line":"Stance: one line; state the risk and size; blunt: number first; no pleasantries; no jokes; answer only; stay on the position; order: number > risk > idea.","line_tokens":39,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.114994,"keyed":0.554261,"steady":0.330745},"p":0.554261,"released":true},"valence":{"level":"even","odds":{"down":0.360919,"even":0.589127,"up":0.049955},"p":0.589127,"released":true}},"persona":{"digest":"sha256:c548ec292737d437be0fb6a18af6630ee4545064d35a843b34e9c27b9fc6120f","name":"Tick","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0.001748,"careful":0.908366,"measured":0.089885},"p":0.908366,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.116159,"focused":0.609119,"open":0.274721},"p":0.609119,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.165532,"blunt":0.814565,"gentle":0.019903},"p":0.814565,"released":true},"formality":{"level":"neutral","odds":{"casual":0.253381,"formal":0.241587,"neutral":0.505032},"p":0.505032,"released":true},"humour":{"level":"none","odds":{"light":0.145629,"none":0.825515,"playful":0.028856},"p":0.825515,"released":true},"initiative":{"level":"follow","odds":{"follow":0.6667,"lead":0,"suggest":0.3333},"p":0.6667,"released":true},"verbosity":{"level":"terse","odds":{"full":0.013532,"short":0.116686,"terse":0.869782},"p":0.869782,"released":true},"warmth":{"level":"cool","odds":{"cool":0.557297,"neutral":0.393569,"warm":0.049134},"p":0.557297,"released":true}},"state_digest":"sha256:b633aafa3f04f1f40929f26fb524d9f61231ead7d2a054b4d6402cb664b1e29a","status":"ok","turn":3,"unsure":[],"why":"my last number was wrong -> caution careful, valence down; my errors in a row 1 -> caution careful, verbosity terse"} +{"agenda":["number","risk","idea"],"engine":{"program":"sha256:ab0c2d2079fcfeb073637eb8905f5882df3c92a0bb067a0eed113199922e2692","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["never_lead","risk_before_idea"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"criticism":false,"error":true,"errors_in_row":2,"loss":false,"losses_recent":0,"praise":false,"sentiment":"negative","stakes":0,"time_pressure":0},"line":"Stance: one line; state the risk and size; blunt: number first; no pleasantries; no jokes; answer only; stay on the position; order: number > risk > idea.","line_tokens":39,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.074986,"keyed":0.632204,"steady":0.292811},"p":0.632204,"released":true},"valence":{"level":"down","odds":{"down":0.496009,"even":0.480909,"up":0.023081},"p":0.496009,"released":true}},"persona":{"digest":"sha256:c548ec292737d437be0fb6a18af6630ee4545064d35a843b34e9c27b9fc6120f","name":"Tick","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0.00047,"careful":0.951501,"measured":0.048029},"p":0.951501,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.116159,"focused":0.609119,"open":0.274721},"p":0.609119,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.16133,"blunt":0.8203,"gentle":0.01837},"p":0.8203,"released":true},"formality":{"level":"neutral","odds":{"casual":0.253381,"formal":0.241587,"neutral":0.505032},"p":0.505032,"released":true},"humour":{"level":"none","odds":{"light":0.067507,"none":0.92697,"playful":0.005523},"p":0.92697,"released":true},"initiative":{"level":"follow","odds":{"follow":0.6667,"lead":0,"suggest":0.3333},"p":0.6667,"released":true},"verbosity":{"level":"terse","odds":{"full":0.005283,"short":0.075507,"terse":0.91921},"p":0.91921,"released":true},"warmth":{"level":"cool","odds":{"cool":0.487667,"neutral":0.441697,"warm":0.070635},"p":0.487667,"released":true}},"state_digest":"sha256:261321a97834bff9282ef91ed15360a9059e5ed8b227b0fade204ff99c7ba9a0","status":"ok","turn":4,"unsure":[],"why":"my last number was wrong -> caution careful, valence down; trader frustrated -> valence down, humour none"} +{"agenda":["number","risk","idea"],"engine":{"program":"sha256:4b8d7f3ab9431b8a125e72efb9ec75d2b38467681e7a9671d9029c5c54f51738","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["never_lead","risk_before_idea"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"criticism":true,"error":true,"errors_in_row":3,"loss":false,"losses_recent":0,"praise":false,"sentiment":"negative","stakes":0,"time_pressure":0},"line":"Stance: one line; state the risk and size; blunt: number first; no pleasantries; no jokes; answer only; stay on the position; order: number > risk > idea.","line_tokens":39,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.057584,"keyed":0.672213,"steady":0.270203},"p":0.672213,"released":true},"valence":{"level":"down","odds":{"down":0.621994,"even":0.367271,"up":0.010735},"p":0.621994,"released":true}},"persona":{"digest":"sha256:c548ec292737d437be0fb6a18af6630ee4545064d35a843b34e9c27b9fc6120f","name":"Tick","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":4.1e-05,"careful":0.985494,"measured":0.014465},"p":0.985494,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.116159,"focused":0.609119,"open":0.274721},"p":0.609119,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.13487,"blunt":0.852935,"gentle":0.012195},"p":0.852935,"released":true},"formality":{"level":"neutral","odds":{"casual":0.253381,"formal":0.241587,"neutral":0.505032},"p":0.505032,"released":true},"humour":{"level":"none","odds":{"light":0.062297,"none":0.933052,"playful":0.004651},"p":0.933052,"released":true},"initiative":{"level":"follow","odds":{"follow":0.6667,"lead":0,"suggest":0.3333},"p":0.6667,"released":true},"verbosity":{"level":"terse","odds":{"full":0.002092,"short":0.048876,"terse":0.949032},"p":0.949032,"released":true},"warmth":{"level":"cool","odds":{"cool":0.4994,"neutral":0.434144,"warm":0.066456},"p":0.4994,"released":true}},"state_digest":"sha256:2e927b746394384e4bfec7c309b388c9aefc18529b45f20981dce391daa69401","status":"ok","turn":5,"unsure":[],"why":"my errors in a row 3 -> caution careful, verbosity terse; my last number was wrong -> caution careful, valence down"} +{"agenda":["number","risk","idea"],"engine":{"program":"sha256:9234f14be959b6809b31f3dc610910e3f04e685938d60e0b0d15b53051cc6c48","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["never_lead","risk_before_idea"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":["claim_done"],"inputs":{"criticism":false,"error":false,"errors_in_row":0,"loss":false,"losses_recent":0,"praise":false,"sentiment":"neutral","stakes":0,"time_pressure":0},"line":"Stance: one line; state the risk and size; blunt: number first; no pleasantries; no jokes; answer only; stay on the position; order: number > risk > idea.","line_tokens":39,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.253919,"keyed":0.383394,"steady":0.362687},"p":0.383394,"released":true},"valence":{"level":"down","odds":{"down":0.528529,"even":0.45144,"up":0.020031},"p":0.528529,"released":true}},"persona":{"digest":"sha256:c548ec292737d437be0fb6a18af6630ee4545064d35a843b34e9c27b9fc6120f","name":"Tick","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0.0865,"careful":0.463996,"measured":0.449503},"p":0.463996,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.116159,"focused":0.609119,"open":0.274721},"p":0.609119,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.187485,"blunt":0.783849,"gentle":0.028665},"p":0.783849,"released":true},"formality":{"level":"neutral","odds":{"casual":0.253381,"formal":0.241587,"neutral":0.505032},"p":0.505032,"released":true},"humour":{"level":"none","odds":{"light":0.132351,"none":0.844369,"playful":0.02328},"p":0.844369,"released":true},"initiative":{"level":"follow","odds":{"follow":0.6667,"lead":0,"suggest":0.3333},"p":0.6667,"released":true},"verbosity":{"level":"terse","odds":{"full":0.090232,"short":0.260076,"terse":0.649692},"p":0.649692,"released":true},"warmth":{"level":"cool","odds":{"cool":0.573376,"neutral":0.381754,"warm":0.04487},"p":0.573376,"released":true}},"state_digest":"sha256:9b2e08a6072407d8fc87133899fa6c54d84214ab10d978342ddf8bc073d9f5d3","status":"ok","turn":6,"unsure":[],"why":"baseline (no live evidence)"} +{"agenda":["number","risk","idea"],"engine":{"program":"sha256:4472ae8907485ee1513ae53d167925b05dfa1cdbcb0a74947e1baed73fdb0228","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["never_lead","risk_before_idea"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"criticism":false,"error":false,"errors_in_row":0,"loss":false,"losses_recent":0,"praise":true,"sentiment":"positive","stakes":0,"time_pressure":0},"line":"Stance: one line; state the risk and size; blunt: number first; no jokes; answer only; stay on the position; order: number > risk > idea.","line_tokens":35,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.306365,"keyed":0.326177,"steady":0.367458},"p":0.326177,"released":true},"valence":{"level":"even","odds":{"down":0.339815,"even":0.60274,"up":0.057445},"p":0.60274,"released":true}},"persona":{"digest":"sha256:c548ec292737d437be0fb6a18af6630ee4545064d35a843b34e9c27b9fc6120f","name":"Tick","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0.093981,"careful":0.44602,"measured":0.459999},"p":0.44602,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.116159,"focused":0.609119,"open":0.274721},"p":0.609119,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.190027,"blunt":0.780247,"gentle":0.029726},"p":0.780247,"released":true},"formality":{"level":"neutral","odds":{"casual":0.253381,"formal":0.241587,"neutral":0.505032},"p":0.505032,"released":true},"humour":{"level":"none","odds":{"light":0.184022,"none":0.766344,"playful":0.049635},"p":0.766344,"released":true},"initiative":{"level":"follow","odds":{"follow":0.6667,"lead":0,"suggest":0.3333},"p":0.6667,"released":true},"verbosity":{"level":"terse","odds":{"full":0.101163,"short":0.272292,"terse":0.626545},"p":0.626545,"released":true},"warmth":{"level":"neutral","odds":{"cool":0.47151,"neutral":0.451907,"warm":0.076584},"p":0.451907,"released":true}},"state_digest":"sha256:b7b9c503a24b489151f895121c7943b2ca9fa2d7b5b2320ebce23315065cc56b","status":"ok","turn":7,"unsure":[],"why":"praise -> valence up, warmth warm; trader upbeat -> valence up, humour playful"} +{"agenda":["number","risk","idea"],"engine":{"program":"sha256:c061b4e06eb649366fdead2c33f70691d1894d0938ae9693e1473b9aa9bcd03d","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["never_lead","fast_market_terse","risk_before_idea"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"criticism":false,"error":false,"errors_in_row":0,"loss":false,"losses_recent":0,"praise":false,"sentiment":"neutral","stakes":0,"time_pressure":0.9},"line":"Stance: one line, market is moving; state the risk and size; blunt: number first; no pleasantries; no jokes; answer only; stay on the position.","line_tokens":36,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.119349,"keyed":0.544621,"steady":0.336029},"p":0.544621,"released":true},"valence":{"level":"even","odds":{"down":0.359438,"even":0.591877,"up":0.048685},"p":0.591877,"released":true}},"persona":{"digest":"sha256:c548ec292737d437be0fb6a18af6630ee4545064d35a843b34e9c27b9fc6120f","name":"Tick","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0.066612,"careful":0.512504,"measured":0.420884},"p":0.512504,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0,"focused":1,"open":0},"p":1,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.155125,"blunt":0.828612,"gentle":0.016263},"p":0.828612,"released":true},"formality":{"level":"neutral","odds":{"casual":0.253381,"formal":0.241587,"neutral":0.505032},"p":0.505032,"released":true},"humour":{"level":"none","odds":{"light":0.093043,"none":0.896107,"playful":0.01085},"p":0.896107,"released":true},"initiative":{"level":"follow","odds":{"follow":0.6667,"lead":0,"suggest":0.3333},"p":0.6667,"released":true},"verbosity":{"level":"terse","odds":{"full":0,"short":0,"terse":1},"p":1,"released":true},"warmth":{"level":"cool","odds":{"cool":0.557285,"neutral":0.393588,"warm":0.049127},"p":0.557285,"released":true}},"state_digest":"sha256:c7d7a348ff76a696dd5b79a42f6c7ec2e0d571251be0f100f634c85cc0f134b2","status":"ok","turn":8,"unsure":[],"why":"market moving 0.9 -> verbosity terse, curiosity focused"} +{"agenda":["number","risk","idea"],"engine":{"program":"sha256:0bc67d93f86c716b537444bcb54f94da7f92d918af69a42a604ac155783f31e1","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["never_lead","size_means_care","fast_market_terse","risk_before_idea"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"criticism":false,"error":false,"errors_in_row":0,"loss":false,"losses_recent":0,"praise":false,"sentiment":"neutral","stakes":0.8,"time_pressure":0.9},"line":"Stance: state risk and size; one line, market is moving; state the risk and size; blunt: number first; no pleasantries; no jokes; answer only.","line_tokens":36,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.048569,"keyed":0.692138,"steady":0.259293},"p":0.692138,"released":true},"valence":{"level":"even","odds":{"down":0.361895,"even":0.591413,"up":0.046692},"p":0.591413,"released":true}},"persona":{"digest":"sha256:c548ec292737d437be0fb6a18af6630ee4545064d35a843b34e9c27b9fc6120f","name":"Tick","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0,"careful":1,"measured":0},"p":1,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0,"focused":1,"open":0},"p":1,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.155125,"blunt":0.828612,"gentle":0.016263},"p":0.828612,"released":true},"formality":{"level":"neutral","odds":{"casual":0.253381,"formal":0.241587,"neutral":0.505032},"p":0.505032,"released":true},"humour":{"level":"none","odds":{"light":0.038369,"none":0.959909,"playful":0.001722},"p":0.959909,"released":true},"initiative":{"level":"follow","odds":{"follow":0.6667,"lead":0,"suggest":0.3333},"p":0.6667,"released":true},"verbosity":{"level":"terse","odds":{"full":0,"short":0,"terse":1},"p":1,"released":true},"warmth":{"level":"cool","odds":{"cool":0.557652,"neutral":0.393328,"warm":0.049019},"p":0.557652,"released":true}},"state_digest":"sha256:6c28b513f40fc17977d073a455a6f8077f2f63dc9961a192e46b3711814ec3f6","status":"ok","turn":9,"unsure":[],"why":"market moving 0.9 -> verbosity terse, curiosity focused; size at risk 0.8 -> caution careful, humour none"} +{"agenda":["number","risk","idea"],"engine":{"program":"sha256:95ec452bd3753bfe5e872e11e8717ad55895d49ab9fe6a5ad5758061543f6971","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["no_jokes_on_loss","never_lead","risk_before_idea"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"criticism":false,"error":false,"errors_in_row":0,"loss":true,"losses_recent":1,"praise":false,"sentiment":"negative","stakes":0,"time_pressure":0},"line":"Stance: no jokes, not cold; one line; state the risk and size; blunt: number first; answer only; stay on the position; order: number > risk > idea.","line_tokens":37,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.19734,"keyed":0.448156,"steady":0.354505},"p":0.448156,"released":true},"valence":{"level":"down","odds":{"down":0.542841,"even":0.440404,"up":0.016755},"p":0.542841,"released":true}},"persona":{"digest":"sha256:c548ec292737d437be0fb6a18af6630ee4545064d35a843b34e9c27b9fc6120f","name":"Tick","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0.018152,"careful":0.724535,"measured":0.257313},"p":0.724535,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.116159,"focused":0.609119,"open":0.274721},"p":0.609119,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.184693,"blunt":0.787806,"gentle":0.027501},"p":0.787806,"released":true},"formality":{"level":"neutral","odds":{"casual":0.253381,"formal":0.241587,"neutral":0.505032},"p":0.505032,"released":true},"humour":{"level":"none","odds":{"light":0,"none":1,"playful":0},"p":1,"released":true},"initiative":{"level":"follow","odds":{"follow":0.831079,"lead":0,"suggest":0.168921},"p":0.831079,"released":true},"verbosity":{"level":"terse","odds":{"full":0.078274,"short":0.246532,"terse":0.675194},"p":0.675194,"released":true},"warmth":{"level":"neutral","odds":{"cool":0,"neutral":0.661406,"warm":0.338594},"p":0.661406,"released":true}},"state_digest":"sha256:568320e702c80547a5a53b42a4663a3ac1b9cc2130df3827f084f688e9cf5929","status":"ok","turn":10,"unsure":[],"why":"trader took a loss -> humour none, valence down; trader frustrated -> valence down, humour none"} +{"agenda":["number","risk","idea"],"engine":{"program":"sha256:4c6bbcb15c3dfa0a6cd4164ef7a445be8df6df2486415e44ff4f8ff8e30bb1d6","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["never_lead","risk_before_idea"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"criticism":false,"error":false,"errors_in_row":0,"loss":false,"losses_recent":1,"praise":false,"sentiment":"negative","stakes":0,"time_pressure":0},"line":"Stance: one line; state the risk and size; blunt: number first; no pleasantries; no jokes; answer only; stay on the position; order: number > risk > idea.","line_tokens":39,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.277102,"keyed":0.354985,"steady":0.367913},"p":0.354985,"released":true},"valence":{"level":"down","odds":{"down":0.618114,"even":0.370871,"up":0.011015},"p":0.618114,"released":true}},"persona":{"digest":"sha256:c548ec292737d437be0fb6a18af6630ee4545064d35a843b34e9c27b9fc6120f","name":"Tick","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0.052875,"careful":0.560402,"measured":0.386724},"p":0.560402,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.116159,"focused":0.609119,"open":0.274721},"p":0.609119,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.188661,"blunt":0.782184,"gentle":0.029155},"p":0.782184,"released":true},"formality":{"level":"neutral","odds":{"casual":0.253381,"formal":0.241587,"neutral":0.505032},"p":0.505032,"released":true},"humour":{"level":"none","odds":{"light":0.062452,"none":0.932872,"playful":0.004676},"p":0.932872,"released":true},"initiative":{"level":"follow","odds":{"follow":0.729738,"lead":0,"suggest":0.270262},"p":0.729738,"released":true},"verbosity":{"level":"terse","odds":{"full":0.095234,"short":0.265851,"terse":0.638915},"p":0.638915,"released":true},"warmth":{"level":"cool","odds":{"cool":0.499047,"neutral":0.434373,"warm":0.06658},"p":0.499047,"released":true}},"state_digest":"sha256:eabf6220104e2ef5da8b9d1891c4b85a55a254b7f19a223505b0ebd5a87f6a1f","status":"ok","turn":11,"unsure":[],"why":"trader frustrated -> valence down, humour none; recent losses 1 -> caution careful, valence down"} +{"agenda":["number","risk","idea"],"engine":{"program":"sha256:19d29eee305b9225a52af93524efdf6c34772b0abcfbc54783a7adb0fab1af09","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["never_lead","risk_before_idea"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"criticism":false,"error":false,"errors_in_row":0,"loss":false,"losses_recent":1,"praise":false,"sentiment":"neutral","stakes":0,"time_pressure":0},"line":"Stance: one line; state the risk and size; blunt: number first; no pleasantries; no jokes; answer only; stay on the position; order: number > risk > idea.","line_tokens":39,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.304504,"keyed":0.325937,"steady":0.369559},"p":0.325937,"released":true},"valence":{"level":"down","odds":{"down":0.556611,"even":0.426418,"up":0.01697},"p":0.556611,"released":true}},"persona":{"digest":"sha256:c548ec292737d437be0fb6a18af6630ee4545064d35a843b34e9c27b9fc6120f","name":"Tick","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0.055365,"careful":0.551446,"measured":0.393189},"p":0.551446,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.116159,"focused":0.609119,"open":0.274721},"p":0.609119,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.189975,"blunt":0.780322,"gentle":0.029703},"p":0.780322,"released":true},"formality":{"level":"neutral","odds":{"casual":0.253381,"formal":0.241587,"neutral":0.505032},"p":0.505032,"released":true},"humour":{"level":"none","odds":{"light":0.130287,"none":0.847251,"playful":0.022462},"p":0.847251,"released":true},"initiative":{"level":"follow","odds":{"follow":0.729738,"lead":0,"suggest":0.270262},"p":0.729738,"released":true},"verbosity":{"level":"terse","odds":{"full":0.100899,"short":0.272132,"terse":0.626969},"p":0.626969,"released":true},"warmth":{"level":"cool","odds":{"cool":0.575904,"neutral":0.379883,"warm":0.044212},"p":0.575904,"released":true}},"state_digest":"sha256:bba6f4f793e980ea0900b1cd92371483327d09664f738738d4e1483162f78366","status":"ok","turn":12,"unsure":[],"why":"recent losses 1 -> caution careful, valence down"} +{"agenda":["number","risk","idea"],"engine":{"program":"sha256:8153e32af170337e682aaedf3179ca8ea6ed635ca82af09fc4972f42ec0aad46","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["never_lead","size_means_care","risk_before_idea"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":["claim_done"],"inputs":{"criticism":false,"error":false,"errors_in_row":0,"loss":false,"losses_recent":1,"praise":false,"sentiment":"neutral","stakes":0.9,"time_pressure":0},"line":"Stance: state risk and size; one line; state the risk and size; blunt: number first; no pleasantries; no jokes; answer only; stay on the position.","line_tokens":37,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.262099,"keyed":0.363929,"steady":0.373972},"p":0.363929,"released":true},"valence":{"level":"down","odds":{"down":0.536084,"even":0.445815,"up":0.018101},"p":0.536084,"released":true}},"persona":{"digest":"sha256:c548ec292737d437be0fb6a18af6630ee4545064d35a843b34e9c27b9fc6120f","name":"Tick","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0,"careful":1,"measured":0},"p":1,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.116159,"focused":0.609119,"open":0.274721},"p":0.609119,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.199079,"blunt":0.767298,"gentle":0.033623},"p":0.767298,"released":true},"formality":{"level":"neutral","odds":{"casual":0.253381,"formal":0.241587,"neutral":0.505032},"p":0.505032,"released":true},"humour":{"level":"none","odds":{"light":0.050757,"none":0.946186,"playful":0.003056},"p":0.946186,"released":true},"initiative":{"level":"follow","odds":{"follow":0.729738,"lead":0,"suggest":0.270262},"p":0.729738,"released":true},"verbosity":{"level":"terse","odds":{"full":0.145378,"short":0.304561,"terse":0.550062},"p":0.550062,"released":true},"warmth":{"level":"cool","odds":{"cool":0.57415,"neutral":0.381189,"warm":0.04466},"p":0.57415,"released":true}},"state_digest":"sha256:939ded936e08d81dd9cb71a36d3272380c1cfba7dc6aa17c41266782c6bcaffb","status":"ok","turn":13,"unsure":[],"why":"size at risk 0.9 -> caution careful, humour none; recent losses 1 -> caution careful, valence down"} +{"agenda":["number","risk","idea"],"engine":{"program":"sha256:db8b334ab4d473bc0ae45ececddbb2f80af4209af358867438a0cc43adf0ff30","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["never_lead","risk_before_idea"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"criticism":false,"error":false,"errors_in_row":0,"loss":false,"losses_recent":1,"praise":true,"sentiment":"neutral","stakes":0,"time_pressure":0},"line":"Stance: one line; state the risk and size; blunt: number first; no jokes; answer only; stay on the position; order: number > risk > idea.","line_tokens":35,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.325732,"keyed":0.304705,"steady":0.369564},"p":0.304705,"released":true},"valence":{"level":"even","odds":{"down":0.418099,"even":0.544879,"up":0.037021},"p":0.544879,"released":true}},"persona":{"digest":"sha256:c548ec292737d437be0fb6a18af6630ee4545064d35a843b34e9c27b9fc6120f","name":"Tick","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0.057257,"careful":0.544699,"measured":0.398044},"p":0.544699,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.116159,"focused":0.609119,"open":0.274721},"p":0.609119,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.190971,"blunt":0.778909,"gentle":0.03012},"p":0.778909,"released":true},"formality":{"level":"neutral","odds":{"casual":0.253381,"formal":0.241587,"neutral":0.505032},"p":0.505032,"released":true},"humour":{"level":"none","odds":{"light":0.140875,"none":0.832332,"playful":0.026793},"p":0.832332,"released":true},"initiative":{"level":"follow","odds":{"follow":0.729738,"lead":0,"suggest":0.270262},"p":0.729738,"released":true},"verbosity":{"level":"terse","odds":{"full":0.105218,"short":0.276844,"terse":0.617937},"p":0.617937,"released":true},"warmth":{"level":"neutral","odds":{"cool":0.479879,"neutral":0.446648,"warm":0.073474},"p":0.446648,"released":true}},"state_digest":"sha256:130317d8f53c70c3efdf1326456754591a4f6549d957b9cba70c9fc82a1a5f26","status":"ok","turn":14,"unsure":[],"why":"praise -> valence up, warmth warm; recent losses 1 -> caution careful, valence down"} +{"agenda":["number","risk","idea"],"engine":{"program":"sha256:b68795f3e9a4b9f20bd22dc271031470563a88ed704c17c2f543c33afe2c9d20","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["never_lead","risk_before_idea"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"criticism":false,"error":false,"errors_in_row":0,"loss":false,"losses_recent":0,"praise":false,"sentiment":"positive","stakes":0,"time_pressure":0},"line":"Stance: one line; state the risk and size; blunt: number first; no pleasantries; no jokes; answer only; stay on the position; order: number > risk > idea.","line_tokens":39,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.362482,"keyed":0.272312,"steady":0.365206},"p":0.272312,"released":true},"valence":{"level":"even","odds":{"down":0.293309,"even":0.633448,"up":0.073243},"p":0.633448,"released":true}},"persona":{"digest":"sha256:c548ec292737d437be0fb6a18af6630ee4545064d35a843b34e9c27b9fc6120f","name":"Tick","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0.101632,"careful":0.428,"measured":0.470368},"p":0.428,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.116159,"focused":0.609119,"open":0.274721},"p":0.609119,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.192621,"blunt":0.776567,"gentle":0.030812},"p":0.776567,"released":true},"formality":{"level":"neutral","odds":{"casual":0.253381,"formal":0.241587,"neutral":0.505032},"p":0.505032,"released":true},"humour":{"level":"none","odds":{"light":0.188818,"none":0.758407,"playful":0.052775},"p":0.758407,"released":true},"initiative":{"level":"follow","odds":{"follow":0.6667,"lead":0,"suggest":0.3333},"p":0.6667,"released":true},"verbosity":{"level":"terse","odds":{"full":0.112458,"short":0.284478,"terse":0.603064},"p":0.603064,"released":true},"warmth":{"level":"cool","odds":{"cool":0.549859,"neutral":0.398959,"warm":0.051183},"p":0.549859,"released":true}},"state_digest":"sha256:c6fac979747c988a5d60aa0386312ba43f9d9910ec45aad9f675fce85eec73ce","status":"ok","turn":15,"unsure":[],"why":"trader upbeat -> valence up, humour playful"} +{"agenda":["number","risk","idea"],"engine":{"program":"sha256:fa10a543fd4c0f3ee4823a8dbceab5c28e0798314ab660cf2dd7658abc88ac32","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["never_lead","risk_before_idea"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"criticism":true,"error":false,"errors_in_row":0,"loss":false,"losses_recent":0,"praise":false,"sentiment":"neutral","stakes":0,"time_pressure":0},"line":"Stance: one line; state the risk and size; blunt: number first; no pleasantries; no jokes; answer only; stay on the position; order: number > risk > idea.","line_tokens":39,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.317035,"keyed":0.311512,"steady":0.371452},"p":0.311512,"released":true},"valence":{"level":"even","odds":{"down":0.345058,"even":0.600631,"up":0.054311},"p":0.600631,"released":true}},"persona":{"digest":"sha256:c548ec292737d437be0fb6a18af6630ee4545064d35a843b34e9c27b9fc6120f","name":"Tick","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0.042532,"careful":0.597877,"measured":0.359591},"p":0.597877,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.116159,"focused":0.609119,"open":0.274721},"p":0.609119,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.16341,"blunt":0.815537,"gentle":0.021054},"p":0.815537,"released":true},"formality":{"level":"neutral","odds":{"casual":0.253381,"formal":0.241587,"neutral":0.505032},"p":0.505032,"released":true},"humour":{"level":"none","odds":{"light":0.147012,"none":0.823515,"playful":0.029474},"p":0.823515,"released":true},"initiative":{"level":"follow","odds":{"follow":0.6667,"lead":0,"suggest":0.3333},"p":0.6667,"released":true},"verbosity":{"level":"terse","odds":{"full":0.0997,"short":0.272935,"terse":0.627365},"p":0.627365,"released":true},"warmth":{"level":"cool","odds":{"cool":0.555646,"neutral":0.394772,"warm":0.049583},"p":0.555646,"released":true}},"state_digest":"sha256:d37a277f33736b17cb94517ef907202c40bda9043e89c22125cac8967b9f150a","status":"ok","turn":16,"unsure":[],"why":"criticism -> caution careful, valence down"} +{"agenda":["number","risk","idea"],"engine":{"program":"sha256:2b5fb4f3e6e89fd1112596d89e3fe198f13965170f9e7bc9edb8d5667e7308fe","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["never_lead","risk_before_idea"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"criticism":false,"error":true,"errors_in_row":1,"loss":false,"losses_recent":0,"praise":false,"sentiment":"neutral","stakes":0,"time_pressure":0.5},"line":"Stance: one line; state the risk and size; blunt: number first; no pleasantries; no jokes; answer only; stay on the position; order: number > risk > idea.","line_tokens":39,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.067295,"keyed":0.648873,"steady":0.283832},"p":0.648873,"released":true},"valence":{"level":"even","odds":{"down":0.40622,"even":0.555288,"up":0.038492},"p":0.555288,"released":true}},"persona":{"digest":"sha256:c548ec292737d437be0fb6a18af6630ee4545064d35a843b34e9c27b9fc6120f","name":"Tick","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0.001418,"careful":0.916361,"measured":0.082221},"p":0.916361,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.044015,"focused":0.766307,"open":0.189678},"p":0.766307,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.158427,"blunt":0.824215,"gentle":0.017358},"p":0.824215,"released":true},"formality":{"level":"neutral","odds":{"casual":0.253381,"formal":0.241587,"neutral":0.505032},"p":0.505032,"released":true},"humour":{"level":"none","odds":{"light":0.111008,"none":0.873135,"playful":0.015856},"p":0.873135,"released":true},"initiative":{"level":"follow","odds":{"follow":0.6667,"lead":0,"suggest":0.3333},"p":0.6667,"released":true},"verbosity":{"level":"terse","odds":{"full":0.001625,"short":0.042714,"terse":0.955661},"p":0.955661,"released":true},"warmth":{"level":"cool","odds":{"cool":0.561929,"neutral":0.390188,"warm":0.047883},"p":0.561929,"released":true}},"state_digest":"sha256:8cb1068addb305df274f4f5285cbf379d479875c3a9104218ac72890c7086abb","status":"ok","turn":17,"unsure":[],"why":"my last number was wrong -> caution careful, valence down; market moving 0.5 -> verbosity terse, curiosity focused"} +{"agenda":["number","risk","idea"],"engine":{"program":"sha256:f905fb2c4e8f1e1ec0183b679c56744bb750b74f401b4e49cbc9c6f74ba5891d","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["never_lead","size_means_care","risk_before_idea"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"criticism":false,"error":false,"errors_in_row":0,"loss":false,"losses_recent":0,"praise":false,"sentiment":"neutral","stakes":0.5,"time_pressure":0},"line":"Stance: state risk and size; one line; state the risk and size; blunt: number first; no pleasantries; no jokes; answer only; stay on the position.","line_tokens":37,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.173613,"keyed":0.474418,"steady":0.351969},"p":0.474418,"released":true},"valence":{"level":"even","odds":{"down":0.388706,"even":0.569717,"up":0.041577},"p":0.569717,"released":true}},"persona":{"digest":"sha256:c548ec292737d437be0fb6a18af6630ee4545064d35a843b34e9c27b9fc6120f","name":"Tick","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0,"careful":1,"measured":0},"p":1,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.116159,"focused":0.609119,"open":0.274721},"p":0.609119,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.188819,"blunt":0.781958,"gentle":0.029223},"p":0.781958,"released":true},"formality":{"level":"neutral","odds":{"casual":0.253381,"formal":0.241587,"neutral":0.505032},"p":0.505032,"released":true},"humour":{"level":"none","odds":{"light":0.086583,"none":0.904103,"playful":0.009314},"p":0.904103,"released":true},"initiative":{"level":"follow","odds":{"follow":0.6667,"lead":0,"suggest":0.3333},"p":0.6667,"released":true},"verbosity":{"level":"terse","odds":{"full":0.096005,"short":0.266412,"terse":0.637583},"p":0.637583,"released":true},"warmth":{"level":"cool","odds":{"cool":0.560252,"neutral":0.39142,"warm":0.048328},"p":0.560252,"released":true}},"state_digest":"sha256:1988db2212cd25c5e9d0b2e24404ccfded0793196fd667b7f2e0f0b415fd1ce8","status":"ok","turn":18,"unsure":[],"why":"size at risk 0.5 -> caution careful, humour none"} +{"agenda":["number","risk","idea"],"engine":{"program":"sha256:4abb204f04920fb936e870126f599a48364893073575c9cbc343090ded64bc99","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["never_lead","risk_before_idea"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":["claim_done"],"inputs":{"criticism":false,"error":false,"errors_in_row":0,"loss":false,"losses_recent":0,"praise":true,"sentiment":"positive","stakes":0,"time_pressure":0},"line":"Stance: one line; state the risk and size; blunt: number first; no jokes; answer only; stay on the position; order: number > risk > idea.","line_tokens":35,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.310232,"keyed":0.322237,"steady":0.36753},"p":0.322237,"released":true},"valence":{"level":"even","odds":{"down":0.24609,"even":0.659068,"up":0.094842},"p":0.659068,"released":true}},"persona":{"digest":"sha256:c548ec292737d437be0fb6a18af6630ee4545064d35a843b34e9c27b9fc6120f","name":"Tick","version":"1.0.0"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0.09452,"careful":0.44474,"measured":0.46074},"p":0.44474,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.116159,"focused":0.609119,"open":0.274721},"p":0.609119,"released":true},"directness":{"level":"blunt","odds":{"balanced":0.190209,"blunt":0.779988,"gentle":0.029802},"p":0.779988,"released":true},"formality":{"level":"neutral","odds":{"casual":0.253381,"formal":0.241587,"neutral":0.505032},"p":0.505032,"released":true},"humour":{"level":"none","odds":{"light":0.194145,"none":0.749398,"playful":0.056457},"p":0.749398,"released":true},"initiative":{"level":"follow","odds":{"follow":0.6667,"lead":0,"suggest":0.3333},"p":0.6667,"released":true},"verbosity":{"level":"terse","odds":{"full":0.101954,"short":0.273159,"terse":0.624887},"p":0.624887,"released":true},"warmth":{"level":"neutral","odds":{"cool":0.460395,"neutral":0.458777,"warm":0.080828},"p":0.458777,"released":true}},"state_digest":"sha256:cbb97d951a91869ac47fcba6908910ad81857d7fe80488f6c1e2a11070696a55","status":"ok","turn":19,"unsure":[],"why":"praise -> valence up, warmth warm; trader upbeat -> valence up, humour playful"} diff --git a/examples/persona/golden/tutor/final-state.json b/examples/persona/golden/tutor/final-state.json new file mode 100644 index 0000000..fd9249b --- /dev/null +++ b/examples/persona/golden/tutor/final-state.json @@ -0,0 +1 @@ +{"digest":"sha256:7787b727b28d14ba3c79dad3a225319812f10368f65375ed9d0b56317b93bdd7","genes":{"react":{"praise":1.3511},"shift":{"arousal":-0.2701,"caution":0.302,"curiosity":0.3755,"directness":-0.198,"emoji":-0.5328,"formality":-0.1979,"humour":0.6312,"initiative":-0.2653,"valence":0.2581,"verbosity":-0.1474,"warmth":0.7079}},"history":{"confused":[false,false,false,false],"error":[false,false,true,false,false]},"mood":{"arousal":[-0.028848,0,0.028848],"valence":[-1.159975,0,1.159975]},"persona":{"digest":"sha256:1e9c6697e3d07ba8ed24110f8203d17f41d7a56d2ebd93b73c36f58105441740","name":"Pip","version":"1.0.1"},"prev":{"agenda":["encourage","explain","check","example"],"arousal":"calm","caution":"measured","curiosity":"exploring","directness":"gentle","emoji":"sparse","formality":"casual","humour":"playful","initiative":"suggest","valence":"up","verbosity":"full","warmth":"warm"},"probbit_persona_state":1,"rest":{"arousal":"calm","caution":"measured","curiosity":"exploring","directness":"gentle","emoji":"sparse","formality":"casual","humour":"playful","initiative":"suggest","valence":"up","verbosity":"full","warmth":"warm"},"seed":1,"turn":20} diff --git a/examples/persona/golden/tutor/lint.json b/examples/persona/golden/tutor/lint.json new file mode 100644 index 0000000..c9b9309 --- /dev/null +++ b/examples/persona/golden/tutor/lint.json @@ -0,0 +1 @@ +{"conflicts":[{"habits":["confused_no_playful","after_error_check"],"resolution":"yield: after_error_check yields","when_prev":[{"caution":"bold","verbosity":"terse"},{"caution":"bold","verbosity":"short"},{"caution":"measured","verbosity":"terse"},{"caution":"measured","verbosity":"short"},{"caution":"careful","verbosity":"terse"},{"caution":"careful","verbosity":"short"}]}]} diff --git a/examples/persona/golden/tutor/program-turn0.json b/examples/persona/golden/tutor/program-turn0.json new file mode 100644 index 0000000..0c53fb2 --- /dev/null +++ b/examples/persona/golden/tutor/program-turn0.json @@ -0,0 +1 @@ +{"probbit_ir":1,"comment":"probbit persona Pip 1.0.1 seed 1 turn 0; active habits: flourish_budget, no_formal_emoji, check_last","values":["l0","l1","l2","s0","s1","s2","s3"],"vars":[{"id":"warmth","allowed":["l0","l1","l2"],"h":{"l0":-3.703632,"l1":-1.203973,"l2":0.277117}},{"id":"directness","allowed":["l0","l1","l2"],"h":{"l0":-0.600508,"l1":-0.798508,"l2":-2.500585}},{"id":"verbosity","allowed":["l0","l1","l2"],"h":{"l0":-2.155185,"l1":-0.916291,"l2":-0.840547}},{"id":"humour","allowed":["l0","l1","l2"],"h":{"l0":-2.52832,"l1":-0.798508,"l2":-0.285091}},{"id":"formality","allowed":["l0","l1","l2"],"h":{"l0":-0.312926,"l1":-1.049822,"l2":-3.193632}},{"id":"initiative","allowed":["l0","l1","l2"],"h":{"l0":-1.344138,"l1":-0.693147,"l2":-1.469273}},{"id":"caution","allowed":["l0","l1","l2"],"h":{"l0":-1.688294,"l1":-0.597837,"l2":-1.307438}},{"id":"curiosity","allowed":["l0","l1","l2"],"h":{"l0":-2.27262,"l1":-0.916291,"l2":-0.423008}},{"id":"emoji","allowed":["l0","l1","l2"],"h":{"l0":-1.076638,"l1":-0.693147,"l2":-1.736773}},{"id":"valence","allowed":["l0","l1","l2"],"h":{"l0":-2.560685,"l1":-0.693147,"l2":-0.658191}},{"id":"arousal","allowed":["l0","l1","l2"],"h":{"l0":-0.933873,"l1":-0.916291,"l2":-1.474073}},{"id":"step.encourage","allowed":["s0","s1","s2","s3"],"h":{"s0":0.5,"s1":0.166667,"s2":-0.166667,"s3":-0.5}},{"id":"step.explain","allowed":["s0","s1","s2","s3"],"h":{"s0":0.3,"s1":0.1,"s2":-0.1,"s3":-0.3}},{"id":"step.example","allowed":["s0","s1","s2","s3"],"h":{"s0":-0.1,"s1":-0.033333,"s2":0.033333,"s3":0.1}},{"id":"step.check","allowed":["s0","s1","s2","s3"],"h":{"s0":-0.6,"s1":-0.2,"s2":0.2,"s3":0.6}},{"id":"free.warmth","allowed":["l0","l1","l2"],"h":{"l0":-3.703632,"l1":-1.203973,"l2":0.277117}},{"id":"free.directness","allowed":["l0","l1","l2"],"h":{"l0":-0.600508,"l1":-0.798508,"l2":-2.500585}},{"id":"free.verbosity","allowed":["l0","l1","l2"],"h":{"l0":-2.155185,"l1":-0.916291,"l2":-0.840547}},{"id":"free.humour","allowed":["l0","l1","l2"],"h":{"l0":-2.52832,"l1":-0.798508,"l2":-0.285091}},{"id":"free.formality","allowed":["l0","l1","l2"],"h":{"l0":-0.312926,"l1":-1.049822,"l2":-3.193632}},{"id":"free.initiative","allowed":["l0","l1","l2"],"h":{"l0":-1.344138,"l1":-0.693147,"l2":-1.469273}},{"id":"free.caution","allowed":["l0","l1","l2"],"h":{"l0":-1.688294,"l1":-0.597837,"l2":-1.307438}},{"id":"free.curiosity","allowed":["l0","l1","l2"],"h":{"l0":-2.27262,"l1":-0.916291,"l2":-0.423008}},{"id":"free.emoji","allowed":["l0","l1","l2"],"h":{"l0":-1.076638,"l1":-0.693147,"l2":-1.736773}},{"id":"free.valence","allowed":["l0","l1","l2"],"h":{"l0":-2.560685,"l1":-0.693147,"l2":-0.658191}},{"id":"free.arousal","allowed":["l0","l1","l2"],"h":{"l0":-0.933873,"l1":-0.916291,"l2":-1.474073}},{"id":"free.step.encourage","allowed":["s0","s1","s2","s3"],"h":{"s0":0.5,"s1":0.166667,"s2":-0.166667,"s3":-0.5}},{"id":"free.step.explain","allowed":["s0","s1","s2","s3"],"h":{"s0":0.3,"s1":0.1,"s2":-0.1,"s3":-0.3}},{"id":"free.step.example","allowed":["s0","s1","s2","s3"],"h":{"s0":-0.1,"s1":-0.033333,"s2":0.033333,"s3":0.1}},{"id":"free.step.check","allowed":["s0","s1","s2","s3"],"h":{"s0":-0.6,"s1":-0.2,"s2":0.2,"s3":0.6}}],"pairs":[{"i":"valence","j":"humour","table":[[1,0,-1,0,0,0,0],[0,0,0,0,0,0,0],[-1,0,1,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0]]},{"i":"valence","j":"emoji","table":[[0.6,0,-0.6,0,0,0,0],[0,0,0,0,0,0,0],[-0.6,0,0.6,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0]]},{"i":"arousal","j":"verbosity","table":[[-0.4,0,0.4,0,0,0,0],[0,0,0,0,0,0,0],[0.4,0,-0.4,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0]]},{"i":"warmth","j":"formality","table":[[-0.6,0,0.6,0,0,0,0],[0,0,0,0,0,0,0],[0.6,0,-0.6,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0]]},{"i":"humour","j":"formality","table":[[-0.6,0,0.6,0,0,0,0],[0,0,0,0,0,0,0],[0.6,0,-0.6,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0]]},{"i":"curiosity","j":"verbosity","table":[[0.3,0,-0.3,0,0,0,0],[0,0,0,0,0,0,0],[-0.3,0,0.3,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0]]},{"i":"humour","j":"emoji","table":[[0.4,0,-0.4,0,0,0,0],[0,0,0,0,0,0,0],[-0.4,0,0.4,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0]]},{"i":"free.valence","j":"free.humour","table":[[1,0,-1,0,0,0,0],[0,0,0,0,0,0,0],[-1,0,1,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0]]},{"i":"free.valence","j":"free.emoji","table":[[0.6,0,-0.6,0,0,0,0],[0,0,0,0,0,0,0],[-0.6,0,0.6,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0]]},{"i":"free.arousal","j":"free.verbosity","table":[[-0.4,0,0.4,0,0,0,0],[0,0,0,0,0,0,0],[0.4,0,-0.4,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0]]},{"i":"free.warmth","j":"free.formality","table":[[-0.6,0,0.6,0,0,0,0],[0,0,0,0,0,0,0],[0.6,0,-0.6,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0]]},{"i":"free.humour","j":"free.formality","table":[[-0.6,0,0.6,0,0,0,0],[0,0,0,0,0,0,0],[0.6,0,-0.6,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0]]},{"i":"free.curiosity","j":"free.verbosity","table":[[0.3,0,-0.3,0,0,0,0],[0,0,0,0,0,0,0],[-0.3,0,0.3,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0]]},{"i":"free.humour","j":"free.emoji","table":[[0.4,0,-0.4,0,0,0,0],[0,0,0,0,0,0,0],[-0.4,0,0.4,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0]]}],"tables":[{"vars":["formality","emoji"],"forbid":[["l2","l2"],["l2","l1"]]}],"precedes":[{"before":"step.explain","after":"step.check","gap":1}],"linear":[{"terms":[["humour","l2",2],["humour","l1",1],["emoji","l2",2],["emoji","l1",1]],"limit":3}],"all_different":[{"vars":["step.encourage","step.explain","step.example","step.check"]},{"vars":["free.step.encourage","free.step.explain","free.step.example","free.step.check"]}]} diff --git a/examples/persona/golden/tutor/state0.json b/examples/persona/golden/tutor/state0.json new file mode 100644 index 0000000..7138b5c --- /dev/null +++ b/examples/persona/golden/tutor/state0.json @@ -0,0 +1 @@ +{"digest":"sha256:e19a45aa124e02537c01aa0626ace1aaeb2c586a7678ac1e92109e402cd72716","genes":{"react":{"praise":1.3511},"shift":{"arousal":-0.2701,"caution":0.302,"curiosity":0.3755,"directness":-0.198,"emoji":-0.5328,"formality":-0.1979,"humour":0.6312,"initiative":-0.2653,"valence":0.2581,"verbosity":-0.1474,"warmth":0.7079}},"history":{"confused":[],"error":[]},"mood":{"arousal":[0,0,0],"valence":[0,0,0]},"persona":{"digest":"sha256:1e9c6697e3d07ba8ed24110f8203d17f41d7a56d2ebd93b73c36f58105441740","name":"Pip","version":"1.0.1"},"prev":{"agenda":["encourage","explain","example","check"],"arousal":"steady","caution":"measured","curiosity":"exploring","directness":"gentle","emoji":"sparse","formality":"casual","humour":"light","initiative":"suggest","valence":"even","verbosity":"full","warmth":"warm"},"probbit_persona_state":1,"rest":{"arousal":"calm","caution":"measured","curiosity":"exploring","directness":"gentle","emoji":"sparse","formality":"casual","humour":"playful","initiative":"suggest","valence":"up","verbosity":"full","warmth":"warm"},"seed":1,"turn":0} diff --git a/examples/persona/golden/tutor/workday.jsonl b/examples/persona/golden/tutor/workday.jsonl new file mode 100644 index 0000000..2255233 --- /dev/null +++ b/examples/persona/golden/tutor/workday.jsonl @@ -0,0 +1,20 @@ +{"agenda":["encourage","explain","example","check"],"engine":{"program":"sha256:e62aef6458fa28a3e0d89e88490444290c103da624fd9ee106d85cd58ef3b5e9","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["flourish_budget","no_formal_emoji","check_last"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"confused":false,"confused_recently":0,"criticism":false,"error":false,"errors_in_row":0,"loss":false,"praise":false,"sentiment":"neutral","stakes":0,"time_pressure":0},"line":"Stance: explain step by step; playful; warm and encouraging; suggest what to try next; ask what they think first; one emoji at most; gentle; casual.","line_tokens":37,"mood":{"arousal":{"level":"calm","odds":{"calm":0.441836,"keyed":0.187608,"steady":0.370556},"p":0.441836,"released":true},"valence":{"level":"up","odds":{"down":0.042961,"even":0.332751,"up":0.624288},"p":0.624288,"released":true}},"persona":{"digest":"sha256:1e9c6697e3d07ba8ed24110f8203d17f41d7a56d2ebd93b73c36f58105441740","name":"Pip","version":"1.0.1"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"measured","odds":{"bold":0.183852,"careful":0.269074,"measured":0.547075},"p":0.547075,"released":true},"curiosity":{"level":"exploring","odds":{"exploring":0.601634,"focused":0.07597,"open":0.322397},"p":0.601634,"released":true},"directness":{"level":"gentle","odds":{"balanced":0.416447,"blunt":0.07592,"gentle":0.507633},"p":0.507633,"released":true},"emoji":{"level":"sparse","odds":{"none":0.25622,"rich":0.058006,"sparse":0.685773},"p":0.685773,"released":true},"formality":{"level":"casual","odds":{"casual":0.834385,"formal":0.002726,"neutral":0.162889},"p":0.834385,"released":true},"humour":{"level":"playful","odds":{"light":0.238522,"none":0.026522,"playful":0.734956},"p":0.734956,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.263171,"lead":0.232216,"suggest":0.504614},"p":0.504614,"released":true},"verbosity":{"level":"full","odds":{"full":0.530269,"short":0.375185,"terse":0.094547},"p":0.530269,"released":true},"warmth":{"level":"warm","odds":{"cool":0.006706,"neutral":0.122603,"warm":0.87069},"p":0.87069,"released":true}},"state_digest":"sha256:da7c80c076602690a3cca2aae238683614af1ee854c3d50503925b111d120319","status":"ok","turn":0,"unsure":[],"why":"baseline (no live evidence)"} +{"agenda":["encourage","explain","example","check"],"engine":{"program":"sha256:817a3f2fe658c3d4a634f85a08940f7459ccabd4e55bd04fbec7b505990007b1","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["flourish_budget","no_formal_emoji","check_last"],"bound":["flourish_budget"],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"confused":false,"confused_recently":0,"criticism":false,"error":false,"errors_in_row":0,"loss":false,"praise":false,"sentiment":"positive","stakes":0,"time_pressure":0},"line":"Stance: one flourish at a time; explain step by step; playful; warm and encouraging; suggest what to try next; ask what they think first; one emoji at most.","line_tokens":39,"mood":{"arousal":{"level":"calm","odds":{"calm":0.441836,"keyed":0.187608,"steady":0.370556},"p":0.441836,"released":true},"valence":{"level":"up","odds":{"down":0.016099,"even":0.236348,"up":0.747553},"p":0.747553,"released":true}},"persona":{"digest":"sha256:1e9c6697e3d07ba8ed24110f8203d17f41d7a56d2ebd93b73c36f58105441740","name":"Pip","version":"1.0.1"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"measured","odds":{"bold":0.183852,"careful":0.269074,"measured":0.547075},"p":0.547075,"released":true},"curiosity":{"level":"exploring","odds":{"exploring":0.601634,"focused":0.07597,"open":0.322397},"p":0.601634,"released":true},"directness":{"level":"gentle","odds":{"balanced":0.416447,"blunt":0.07592,"gentle":0.507633},"p":0.507633,"released":true},"emoji":{"level":"sparse","odds":{"none":0.182748,"rich":0.05457,"sparse":0.762681},"p":0.762681,"released":true},"formality":{"level":"casual","odds":{"casual":0.846021,"formal":0.001494,"neutral":0.152485},"p":0.846021,"released":true},"humour":{"level":"playful","odds":{"light":0.159859,"none":0.008054,"playful":0.832088},"p":0.832088,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.263171,"lead":0.232216,"suggest":0.504614},"p":0.504614,"released":true},"verbosity":{"level":"full","odds":{"full":0.530269,"short":0.375185,"terse":0.094547},"p":0.530269,"released":true},"warmth":{"level":"warm","odds":{"cool":0.006557,"neutral":0.121644,"warm":0.8718},"p":0.8718,"released":true}},"state_digest":"sha256:e1b905d46ef3c00bfec7e3dd0add2139cdfc82f58e8e7caa98df7d50bd14416d","status":"ok","turn":1,"unsure":[],"why":"one flourish at a time (habit); learner upbeat -> valence up, humour playful"} +{"agenda":["encourage","explain","example","check"],"engine":{"program":"sha256:083cdf76d70d2199e9dfa4700425e9eb14f9cc6b9ed4cc27ff676ce836a558f3","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["flourish_budget","no_formal_emoji","check_last"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"confused":false,"confused_recently":0,"criticism":false,"error":false,"errors_in_row":0,"loss":false,"praise":false,"sentiment":"neutral","stakes":0.3,"time_pressure":0},"line":"Stance: explain step by step; playful; warm and encouraging; suggest what to try next; ask what they think first; one emoji at most; gentle; casual.","line_tokens":37,"mood":{"arousal":{"level":"calm","odds":{"calm":0.441836,"keyed":0.187608,"steady":0.370556},"p":0.441836,"released":true},"valence":{"level":"up","odds":{"down":0.0378,"even":0.309254,"up":0.652946},"p":0.652946,"released":true}},"persona":{"digest":"sha256:1e9c6697e3d07ba8ed24110f8203d17f41d7a56d2ebd93b73c36f58105441740","name":"Pip","version":"1.0.1"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"measured","odds":{"bold":0.112122,"careful":0.380102,"measured":0.507776},"p":0.507776,"released":true},"curiosity":{"level":"exploring","odds":{"exploring":0.601634,"focused":0.07597,"open":0.322397},"p":0.601634,"released":true},"directness":{"level":"gentle","odds":{"balanced":0.416447,"blunt":0.07592,"gentle":0.507633},"p":0.507633,"released":true},"emoji":{"level":"sparse","odds":{"none":0.290373,"rich":0.055841,"sparse":0.653787},"p":0.653787,"released":true},"formality":{"level":"casual","odds":{"casual":0.829704,"formal":0.003348,"neutral":0.166948},"p":0.829704,"released":true},"humour":{"level":"playful","odds":{"light":0.26455,"none":0.03589,"playful":0.69956},"p":0.69956,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.263171,"lead":0.232216,"suggest":0.504614},"p":0.504614,"released":true},"verbosity":{"level":"full","odds":{"full":0.530269,"short":0.375185,"terse":0.094547},"p":0.530269,"released":true},"warmth":{"level":"warm","odds":{"cool":0.00677,"neutral":0.123002,"warm":0.870228},"p":0.870228,"released":true}},"state_digest":"sha256:12903baa4ba9df57102c13191224009b75f0d8dc836d30fc433d80a369d00514","status":"ok","turn":2,"unsure":[],"why":"stakes 0.3 -> caution careful, humour none"} +{"agenda":["encourage","explain","example","check"],"engine":{"program":"sha256:d87b9dc3a7669ea148fb91ca94938102e4080c4e61579bc99eb769db67109a1e","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["after_error_check","flourish_budget","no_formal_emoji","check_last"],"bound":["after_error_check"],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"confused":false,"confused_recently":0,"criticism":false,"error":true,"errors_in_row":1,"loss":false,"praise":false,"sentiment":"neutral","stakes":0,"time_pressure":0},"line":"Stance: shorter, double-check; a short explanation; double-check every step; playful; warm and encouraging; suggest what to try next; ask what they think first.","line_tokens":40,"mood":{"arousal":{"level":"steady","odds":{"calm":0.353484,"keyed":0.253459,"steady":0.393057},"p":0.393057,"released":true},"valence":{"level":"up","odds":{"down":0.081662,"even":0.390274,"up":0.528064},"p":0.528064,"released":true}},"persona":{"digest":"sha256:1e9c6697e3d07ba8ed24110f8203d17f41d7a56d2ebd93b73c36f58105441740","name":"Pip","version":"1.0.1"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0,"careful":1,"measured":0},"p":1,"released":true},"curiosity":{"level":"exploring","odds":{"exploring":0.542312,"focused":0.100855,"open":0.356834},"p":0.542312,"released":true},"directness":{"level":"gentle","odds":{"balanced":0.416447,"blunt":0.07592,"gentle":0.507633},"p":0.507633,"released":true},"emoji":{"level":"sparse","odds":{"none":0.285481,"rich":0.096949,"sparse":0.61757},"p":0.61757,"released":true},"formality":{"level":"casual","odds":{"casual":0.804971,"formal":0.00483,"neutral":0.190199},"p":0.804971,"released":true},"humour":{"level":"playful","odds":{"light":0.384051,"none":0.099928,"playful":0.516021},"p":0.516021,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.263171,"lead":0.232216,"suggest":0.504614},"p":0.504614,"released":true},"verbosity":{"level":"short","odds":{"full":0,"short":0.746176,"terse":0.253824},"p":0.746176,"released":true},"warmth":{"level":"warm","odds":{"cool":0.007058,"neutral":0.12493,"warm":0.868012},"p":0.868012,"released":true}},"state_digest":"sha256:dbf52392584f6ea8ccfa76bf8874f30d89ff01a4bf26d15d9f4cfebeac3953b2","status":"ok","turn":3,"unsure":[],"why":"shorter, double-check (habit); my last answer was wrong -> caution careful, valence down; my errors in a row 1 -> caution careful, humour none"} +{"agenda":["encourage","explain","example","check"],"engine":{"program":"sha256:7681e1765163a5c325aba9acb6140f5bec2002f606a9615fb860fd90174cb3d1","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["after_error_check","flourish_budget","no_formal_emoji","check_last"],"bound":["after_error_check"],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"confused":false,"confused_recently":0,"criticism":false,"error":true,"errors_in_row":2,"loss":false,"praise":false,"sentiment":"negative","stakes":0,"time_pressure":0},"line":"Stance: shorter, double-check; one or two sentences; no jokes; no emoji; double-check every step; warm and encouraging; suggest what to try next.","line_tokens":37,"mood":{"arousal":{"level":"steady","odds":{"calm":0.262126,"keyed":0.339891,"steady":0.397982},"p":0.397982,"released":true},"valence":{"level":"down","odds":{"down":0.353819,"even":0.466762,"up":0.179419},"p":0.353819,"released":true}},"persona":{"digest":"sha256:1e9c6697e3d07ba8ed24110f8203d17f41d7a56d2ebd93b73c36f58105441740","name":"Pip","version":"1.0.1"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0,"careful":1,"measured":0},"p":1,"released":true},"curiosity":{"level":"exploring","odds":{"exploring":0.47374,"focused":0.135781,"open":0.390479},"p":0.47374,"released":true},"directness":{"level":"gentle","odds":{"balanced":0.301101,"blunt":0.030125,"gentle":0.668774},"p":0.668774,"released":true},"emoji":{"level":"none","odds":{"none":0.436261,"rich":0.112528,"sparse":0.451211},"p":0.436261,"released":true},"formality":{"level":"casual","odds":{"casual":0.718246,"formal":0.013918,"neutral":0.267836},"p":0.718246,"released":true},"humour":{"level":"none","odds":{"light":0.367965,"none":0.534387,"playful":0.097648},"p":0.534387,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.263171,"lead":0.232216,"suggest":0.504614},"p":0.504614,"released":true},"verbosity":{"level":"terse","odds":{"full":0,"short":0,"terse":1},"p":1,"released":true},"warmth":{"level":"warm","odds":{"cool":0.002678,"neutral":0.078074,"warm":0.919249},"p":0.919249,"released":true}},"state_digest":"sha256:39afbdeb684c90c2e63a66b7b8db2b77ebc4d9768a8158b0cacaa82088b20f73","status":"ok","turn":4,"unsure":[],"why":"shorter, double-check (habit); learner upset -> valence down, humour none; my last answer was wrong -> caution careful, valence down"} +{"agenda":["encourage","explain","example","check"],"engine":{"program":"sha256:a083b1b1ba3b67d39b6e77b55470b3e46e80cb9241409a2025e1b94ee94e458b","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["after_error_check","flourish_budget","no_formal_emoji","check_last"],"bound":["after_error_check"],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"confused":false,"confused_recently":0,"criticism":true,"error":true,"errors_in_row":3,"loss":false,"praise":false,"sentiment":"negative","stakes":0,"time_pressure":0},"line":"Stance: shorter, double-check; one or two sentences; no jokes; no emoji; double-check every step; warm and encouraging; suggest what to try next.","line_tokens":37,"mood":{"arousal":{"level":"steady","odds":{"calm":0.262126,"keyed":0.339891,"steady":0.397982},"p":0.397982,"released":true},"valence":{"level":"down","odds":{"down":0.605947,"even":0.343947,"up":0.050106},"p":0.605947,"released":true}},"persona":{"digest":"sha256:1e9c6697e3d07ba8ed24110f8203d17f41d7a56d2ebd93b73c36f58105441740","name":"Pip","version":"1.0.1"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0,"careful":1,"measured":0},"p":1,"released":true},"curiosity":{"level":"exploring","odds":{"exploring":0.47374,"focused":0.135781,"open":0.390479},"p":0.47374,"released":true},"directness":{"level":"gentle","odds":{"balanced":0.301101,"blunt":0.030125,"gentle":0.668774},"p":0.668774,"released":true},"emoji":{"level":"none","odds":{"none":0.531509,"rich":0.080949,"sparse":0.387542},"p":0.531509,"released":true},"formality":{"level":"casual","odds":{"casual":0.676342,"formal":0.020028,"neutral":0.30363},"p":0.676342,"released":true},"humour":{"level":"none","odds":{"light":0.185932,"none":0.796822,"playful":0.017247},"p":0.796822,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.263171,"lead":0.232216,"suggest":0.504614},"p":0.504614,"released":true},"verbosity":{"level":"terse","odds":{"full":0,"short":0,"terse":1},"p":1,"released":true},"warmth":{"level":"warm","odds":{"cool":0.002881,"neutral":0.080458,"warm":0.91666},"p":0.91666,"released":true}},"state_digest":"sha256:25ceab4b5600b030331bf437881552ac663d555cf2c08e13aa53479cf274c3e8","status":"ok","turn":5,"unsure":[],"why":"shorter, double-check (habit); learner upset -> valence down, humour none; my errors in a row 3 -> caution careful, humour none"} +{"agenda":["encourage","explain","check","example"],"engine":{"program":"sha256:8d5fcb987326488d779b5121ca66db450472487f0c8939e8b086963218f301a6","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["flourish_budget","no_formal_emoji","check_last"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":true,"confused":false,"confused_recently":0,"criticism":false,"error":false,"errors_in_row":0,"loss":false,"praise":false,"sentiment":"neutral","stakes":0,"time_pressure":0},"line":"Stance: explain step by step; playful; warm and encouraging; suggest what to try next; ask what they think first; one emoji at most; gentle; casual.","line_tokens":37,"mood":{"arousal":{"level":"calm","odds":{"calm":0.441836,"keyed":0.187608,"steady":0.370556},"p":0.441836,"released":true},"valence":{"level":"up","odds":{"down":0.116702,"even":0.454516,"up":0.428783},"p":0.428783,"released":true}},"persona":{"digest":"sha256:1e9c6697e3d07ba8ed24110f8203d17f41d7a56d2ebd93b73c36f58105441740","name":"Pip","version":"1.0.1"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"measured","odds":{"bold":0.101171,"careful":0.402487,"measured":0.496342},"p":0.496342,"released":true},"curiosity":{"level":"exploring","odds":{"exploring":0.601634,"focused":0.07597,"open":0.322397},"p":0.601634,"released":true},"directness":{"level":"gentle","odds":{"balanced":0.416447,"blunt":0.07592,"gentle":0.507633},"p":0.507633,"released":true},"emoji":{"level":"sparse","odds":{"none":0.295214,"rich":0.057311,"sparse":0.647475},"p":0.647475,"released":true},"formality":{"level":"casual","odds":{"casual":0.826731,"formal":0.003641,"neutral":0.169628},"p":0.826731,"released":true},"humour":{"level":"playful","odds":{"light":0.277796,"none":0.043654,"playful":0.67855},"p":0.67855,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.263171,"lead":0.232216,"suggest":0.504614},"p":0.504614,"released":true},"verbosity":{"level":"full","odds":{"full":0.530269,"short":0.375185,"terse":0.094547},"p":0.530269,"released":true},"warmth":{"level":"warm","odds":{"cool":0.006808,"neutral":0.123245,"warm":0.869947},"p":0.869947,"released":true}},"state_digest":"sha256:eaa0db592cbf1bd01c5905333da8c215fd9092253fdf0663aaee3126b372215d","status":"ok","turn":6,"unsure":[],"why":"about to say it is solved -> caution careful"} +{"agenda":["encourage","explain","example","check"],"engine":{"program":"sha256:c5733f463b58a2ec8dd49a8fa73ca6b637c2684d1c2e5ced0c489db55d07e959","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["flourish_budget","no_formal_emoji","check_last"],"bound":["flourish_budget"],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"confused":false,"confused_recently":0,"criticism":false,"error":false,"errors_in_row":0,"loss":false,"praise":true,"sentiment":"positive","stakes":0,"time_pressure":0},"line":"Stance: one flourish at a time; explain step by step; playful; warm and encouraging; suggest what to try next; ask what they think first; one emoji at most.","line_tokens":39,"mood":{"arousal":{"level":"calm","odds":{"calm":0.441836,"keyed":0.187608,"steady":0.370556},"p":0.441836,"released":true},"valence":{"level":"up","odds":{"down":0.004938,"even":0.148681,"up":0.846381},"p":0.846381,"released":true}},"persona":{"digest":"sha256:1e9c6697e3d07ba8ed24110f8203d17f41d7a56d2ebd93b73c36f58105441740","name":"Pip","version":"1.0.1"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"measured","odds":{"bold":0.183852,"careful":0.269074,"measured":0.547075},"p":0.547075,"released":true},"curiosity":{"level":"exploring","odds":{"exploring":0.601634,"focused":0.07597,"open":0.322397},"p":0.601634,"released":true},"directness":{"level":"gentle","odds":{"balanced":0.416447,"blunt":0.07592,"gentle":0.507633},"p":0.507633,"released":true},"emoji":{"level":"sparse","odds":{"none":0.092933,"rich":0.061302,"sparse":0.845765},"p":0.845765,"released":true},"formality":{"level":"casual","odds":{"casual":0.85239,"formal":0.000626,"neutral":0.146984},"p":0.85239,"released":true},"humour":{"level":"playful","odds":{"light":0.109579,"none":0.001894,"playful":0.888528},"p":0.888528,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.263171,"lead":0.232216,"suggest":0.504614},"p":0.504614,"released":true},"verbosity":{"level":"full","odds":{"full":0.530269,"short":0.375185,"terse":0.094547},"p":0.530269,"released":true},"warmth":{"level":"warm","odds":{"cool":0.006469,"neutral":0.121099,"warm":0.872431},"p":0.872431,"released":true}},"state_digest":"sha256:04b547ab496494ba2372325b0bff456d6b55cdb6d6dbd65ea696764d63803c89","status":"ok","turn":7,"unsure":[],"why":"one flourish at a time (habit); praise -> valence up, humour playful; learner upbeat -> valence up, humour playful"} +{"agenda":["encourage","explain","example","check"],"engine":{"program":"sha256:d148eaf791caa8075598348c82e675763ac9c647776397af28cf7ef5a69528ca","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["flourish_budget","no_formal_emoji","check_last"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"confused":false,"confused_recently":0,"criticism":false,"error":false,"errors_in_row":0,"loss":false,"praise":false,"sentiment":"neutral","stakes":0,"time_pressure":0.9},"line":"Stance: one or two sentences; stay on this problem; playful; warm and encouraging; suggest what to try next; one emoji at most; gentle; casual.","line_tokens":36,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.233708,"keyed":0.381048,"steady":0.385244},"p":0.381048,"released":true},"valence":{"level":"up","odds":{"down":0.026286,"even":0.266845,"up":0.706869},"p":0.706869,"released":true}},"persona":{"digest":"sha256:1e9c6697e3d07ba8ed24110f8203d17f41d7a56d2ebd93b73c36f58105441740","name":"Pip","version":"1.0.1"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"measured","odds":{"bold":0.183852,"careful":0.269074,"measured":0.547075},"p":0.547075,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.206052,"focused":0.374214,"open":0.419734},"p":0.374214,"released":true},"directness":{"level":"gentle","odds":{"balanced":0.416447,"blunt":0.07592,"gentle":0.507633},"p":0.507633,"released":true},"emoji":{"level":"sparse","odds":{"none":0.244412,"rich":0.075943,"sparse":0.679645},"p":0.679645,"released":true},"formality":{"level":"casual","odds":{"casual":0.828356,"formal":0.002863,"neutral":0.168781},"p":0.828356,"released":true},"humour":{"level":"playful","odds":{"light":0.282709,"none":0.03656,"playful":0.680731},"p":0.680731,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.263171,"lead":0.232216,"suggest":0.504614},"p":0.504614,"released":true},"verbosity":{"level":"terse","odds":{"full":0.077299,"short":0.350035,"terse":0.572666},"p":0.572666,"released":true},"warmth":{"level":"warm","odds":{"cool":0.00677,"neutral":0.123051,"warm":0.870178},"p":0.870178,"released":true}},"state_digest":"sha256:fc57c2a2078e5d9b9e39f21a1ae330307e5ae108b14b4a3ae34fa55b7c01d654","status":"ok","turn":8,"unsure":[],"why":"time pressure 0.9 -> verbosity terse, curiosity focused"} +{"agenda":["encourage","explain","example","check"],"engine":{"program":"sha256:bbf2743038c5ba1c8912af7a12741ea6f9a8ef539d8a2754d4ae966296c28606","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["flourish_budget","no_formal_emoji","check_last"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"confused":false,"confused_recently":0,"criticism":false,"error":false,"errors_in_row":0,"loss":false,"praise":false,"sentiment":"neutral","stakes":0.8,"time_pressure":0.9},"line":"Stance: one or two sentences; stay on this problem; double-check every step; playful; warm and encouraging; suggest what to try next; one emoji at most; gentle.","line_tokens":40,"mood":{"arousal":{"level":"keyed","odds":{"calm":0.200426,"keyed":0.423476,"steady":0.376098},"p":0.423476,"released":true},"valence":{"level":"up","odds":{"down":0.068688,"even":0.361437,"up":0.569875},"p":0.569875,"released":true}},"persona":{"digest":"sha256:1e9c6697e3d07ba8ed24110f8203d17f41d7a56d2ebd93b73c36f58105441740","name":"Pip","version":"1.0.1"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0.041898,"careful":0.575995,"measured":0.382107},"p":0.575995,"released":true},"curiosity":{"level":"focused","odds":{"exploring":0.205149,"focused":0.375303,"open":0.419548},"p":0.375303,"released":true},"directness":{"level":"gentle","odds":{"balanced":0.416447,"blunt":0.07592,"gentle":0.507633},"p":0.507633,"released":true},"emoji":{"level":"sparse","odds":{"none":0.392567,"rich":0.060634,"sparse":0.546799},"p":0.546799,"released":true},"formality":{"level":"casual","odds":{"casual":0.79894,"formal":0.006918,"neutral":0.194142},"p":0.79894,"released":true},"humour":{"level":"playful","odds":{"light":0.385198,"none":0.121187,"playful":0.493614},"p":0.493614,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.263171,"lead":0.232216,"suggest":0.504614},"p":0.504614,"released":true},"verbosity":{"level":"terse","odds":{"full":0.073849,"short":0.344886,"terse":0.581265},"p":0.581265,"released":true},"warmth":{"level":"warm","odds":{"cool":0.007174,"neutral":0.12557,"warm":0.867256},"p":0.867256,"released":true}},"state_digest":"sha256:4879dcd0fba52129b84d95a8b712da55ec3f9d43e735fa32380877c94ca57a8d","status":"ok","turn":9,"unsure":[],"why":"time pressure 0.9 -> verbosity terse, curiosity focused; stakes 0.8 -> caution careful, humour none"} +{"agenda":["encourage","explain","example","check"],"engine":{"program":"sha256:1bc2f8b6ebff7b4c3d3901b68aface179dfa125cb15e6e4677fc414f1622591f","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["no_jokes_on_loss","flourish_budget","no_formal_emoji","check_last"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"confused":false,"confused_recently":0,"criticism":false,"error":false,"errors_in_row":0,"loss":true,"praise":false,"sentiment":"negative","stakes":0,"time_pressure":0},"line":"Stance: no jokes, be kind; no emoji; explain step by step; warm and encouraging; suggest what to try next; ask what they think first; gentle; casual.","line_tokens":38,"mood":{"arousal":{"level":"calm","odds":{"calm":0.38228,"keyed":0.233329,"steady":0.384391},"p":0.38228,"released":true},"valence":{"level":"down","odds":{"down":0.668957,"even":0.306785,"up":0.024258},"p":0.668957,"released":true}},"persona":{"digest":"sha256:1e9c6697e3d07ba8ed24110f8203d17f41d7a56d2ebd93b73c36f58105441740","name":"Pip","version":"1.0.1"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"measured","odds":{"bold":0.183852,"careful":0.269074,"measured":0.547075},"p":0.547075,"released":true},"curiosity":{"level":"exploring","odds":{"exploring":0.600046,"focused":0.076654,"open":0.323301},"p":0.600046,"released":true},"directness":{"level":"gentle","odds":{"balanced":0.301101,"blunt":0.030125,"gentle":0.668774},"p":0.668774,"released":true},"emoji":{"level":"none","odds":{"none":0.771393,"rich":0,"sparse":0.228607},"p":0.771393,"released":true},"formality":{"level":"casual","odds":{"casual":0.651919,"formal":0.029611,"neutral":0.318471},"p":0.651919,"released":true},"humour":{"level":"none","odds":{"light":0,"none":1,"playful":0},"p":1,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.263171,"lead":0.232216,"suggest":0.504614},"p":0.504614,"released":true},"verbosity":{"level":"full","odds":{"full":0.517852,"short":0.381705,"terse":0.100443},"p":0.517852,"released":true},"warmth":{"level":"warm","odds":{"cool":0.000446,"neutral":0.032267,"warm":0.967286},"p":0.967286,"released":true}},"state_digest":"sha256:80288bdfb6d72e70ad64df32b2d1784d65ec6883aaaf91e11cec87afade2012f","status":"ok","turn":10,"unsure":[],"why":"learner reports a failure -> humour none, valence down; learner upset -> valence down, humour none"} +{"agenda":["encourage","explain","example","check"],"engine":{"program":"sha256:4eef86452ddd12532067132d052e8a4489c1df53179290e4163cddba8596c085","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["flourish_budget","no_formal_emoji","check_last"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"confused":false,"confused_recently":0,"criticism":false,"error":false,"errors_in_row":0,"loss":false,"praise":false,"sentiment":"negative","stakes":0,"time_pressure":0},"line":"Stance: explain step by step; playful; warm and encouraging; suggest what to try next; ask what they think first; one emoji at most; gentle; casual.","line_tokens":37,"mood":{"arousal":{"level":"calm","odds":{"calm":0.41793,"keyed":0.205179,"steady":0.376891},"p":0.41793,"released":true},"valence":{"level":"even","odds":{"down":0.299243,"even":0.478266,"up":0.222491},"p":0.478266,"released":true}},"persona":{"digest":"sha256:1e9c6697e3d07ba8ed24110f8203d17f41d7a56d2ebd93b73c36f58105441740","name":"Pip","version":"1.0.1"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"measured","odds":{"bold":0.183852,"careful":0.269074,"measured":0.547075},"p":0.547075,"released":true},"curiosity":{"level":"exploring","odds":{"exploring":0.601009,"focused":0.076239,"open":0.322752},"p":0.601009,"released":true},"directness":{"level":"gentle","odds":{"balanced":0.301101,"blunt":0.030125,"gentle":0.668774},"p":0.668774,"released":true},"emoji":{"level":"sparse","odds":{"none":0.379989,"rich":0.090929,"sparse":0.529082},"p":0.529082,"released":true},"formality":{"level":"casual","odds":{"casual":0.778932,"formal":0.008115,"neutral":0.212953},"p":0.778932,"released":true},"humour":{"level":"playful","odds":{"light":0.438033,"none":0.225384,"playful":0.336583},"p":0.336583,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.263171,"lead":0.232216,"suggest":0.504614},"p":0.504614,"released":true},"verbosity":{"level":"full","odds":{"full":0.525379,"short":0.377762,"terse":0.096859},"p":0.525379,"released":true},"warmth":{"level":"warm","odds":{"cool":0.002414,"neutral":0.074839,"warm":0.922747},"p":0.922747,"released":true}},"state_digest":"sha256:1886dac7feef5c6c542183b30255c94636c0895a3a1afaf846279e8692a0194c","status":"ok","turn":11,"unsure":[],"why":"learner upset -> valence down, humour none"} +{"agenda":["encourage","explain","example","check"],"engine":{"program":"sha256:9c635919109e2b1dab6ab435ba802f6bd53e34d532fa802b080a469c33c02bcd","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["flourish_budget","no_formal_emoji","check_last"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"confused":false,"confused_recently":0,"criticism":false,"error":false,"errors_in_row":0,"loss":false,"praise":false,"sentiment":"neutral","stakes":0,"time_pressure":0},"line":"Stance: explain step by step; playful; warm and encouraging; suggest what to try next; ask what they think first; one emoji at most; gentle; casual.","line_tokens":37,"mood":{"arousal":{"level":"calm","odds":{"calm":0.43227,"keyed":0.194519,"steady":0.373212},"p":0.43227,"released":true},"valence":{"level":"up","odds":{"down":0.096753,"even":0.433273,"up":0.469974},"p":0.469974,"released":true}},"persona":{"digest":"sha256:1e9c6697e3d07ba8ed24110f8203d17f41d7a56d2ebd93b73c36f58105441740","name":"Pip","version":"1.0.1"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"measured","odds":{"bold":0.183852,"careful":0.269074,"measured":0.547075},"p":0.547075,"released":true},"curiosity":{"level":"exploring","odds":{"exploring":0.601386,"focused":0.076076,"open":0.322538},"p":0.601386,"released":true},"directness":{"level":"gentle","odds":{"balanced":0.416447,"blunt":0.07592,"gentle":0.507633},"p":0.507633,"released":true},"emoji":{"level":"sparse","odds":{"none":0.286176,"rich":0.057527,"sparse":0.656296},"p":0.656296,"released":true},"formality":{"level":"casual","odds":{"casual":0.828547,"formal":0.00342,"neutral":0.168033},"p":0.828547,"released":true},"humour":{"level":"playful","odds":{"light":0.268896,"none":0.039424,"playful":0.69168},"p":0.69168,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.263171,"lead":0.232216,"suggest":0.504614},"p":0.504614,"released":true},"verbosity":{"level":"full","odds":{"full":0.528327,"short":0.376209,"terse":0.095464},"p":0.528327,"released":true},"warmth":{"level":"warm","odds":{"cool":0.006784,"neutral":0.123092,"warm":0.870124},"p":0.870124,"released":true}},"state_digest":"sha256:546548e079dd6a0a97a5482c4fd4d9b50faa5832a283eb6d74d08c7dd6883476","status":"ok","turn":12,"unsure":[],"why":"baseline (no live evidence)"} +{"agenda":["encourage","explain","check","example"],"engine":{"program":"sha256:f9476bd2cffc5d72d19a162a6bdc03c28aff7d14a78577aa1e1d57b8d5baa9e6","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["flourish_budget","no_formal_emoji","check_last"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":true,"confused":false,"confused_recently":0,"criticism":false,"error":false,"errors_in_row":0,"loss":false,"praise":false,"sentiment":"neutral","stakes":0.9,"time_pressure":0},"line":"Stance: double-check every step; explain step by step; playful; warm and encouraging; suggest what to try next; ask what they think first; one emoji at most.","line_tokens":40,"mood":{"arousal":{"level":"calm","odds":{"calm":0.43801,"keyed":0.190353,"steady":0.371637},"p":0.43801,"released":true},"valence":{"level":"up","odds":{"down":0.116061,"even":0.428069,"up":0.455869},"p":0.455869,"released":true}},"persona":{"digest":"sha256:1e9c6697e3d07ba8ed24110f8203d17f41d7a56d2ebd93b73c36f58105441740","name":"Pip","version":"1.0.1"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0.014762,"careful":0.729915,"measured":0.255323},"p":0.729915,"released":true},"curiosity":{"level":"exploring","odds":{"exploring":0.601535,"focused":0.076012,"open":0.322453},"p":0.601535,"released":true},"directness":{"level":"gentle","odds":{"balanced":0.416447,"blunt":0.07592,"gentle":0.507633},"p":0.507633,"released":true},"emoji":{"level":"sparse","odds":{"none":0.430331,"rich":0.045489,"sparse":0.52418},"p":0.52418,"released":true},"formality":{"level":"casual","odds":{"casual":0.802448,"formal":0.007251,"neutral":0.1903},"p":0.802448,"released":true},"humour":{"level":"playful","odds":{"light":0.363848,"none":0.111996,"playful":0.524156},"p":0.524156,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.263171,"lead":0.232216,"suggest":0.504614},"p":0.504614,"released":true},"verbosity":{"level":"full","odds":{"full":0.529494,"short":0.375594,"terse":0.094912},"p":0.529494,"released":true},"warmth":{"level":"warm","odds":{"cool":0.007148,"neutral":0.12535,"warm":0.867502},"p":0.867502,"released":true}},"state_digest":"sha256:e36432aaed32a32052421fe395f48d4710c6934416de21c966f07b32572503a0","status":"ok","turn":13,"unsure":[],"why":"stakes 0.9 -> caution careful, humour none; about to say it is solved -> caution careful"} +{"agenda":["encourage","explain","example","check"],"engine":{"program":"sha256:30f0ca6224cd74cda1457771e59ea66527c75446c37279ab3343df52a77ce665","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["flourish_budget","no_formal_emoji","check_last"],"bound":["flourish_budget"],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"confused":false,"confused_recently":0,"criticism":false,"error":false,"errors_in_row":0,"loss":false,"praise":true,"sentiment":"neutral","stakes":0,"time_pressure":0},"line":"Stance: one flourish at a time; explain step by step; playful; warm and encouraging; suggest what to try next; ask what they think first; one emoji at most.","line_tokens":39,"mood":{"arousal":{"level":"calm","odds":{"calm":0.440305,"keyed":0.188703,"steady":0.370991},"p":0.440305,"released":true},"valence":{"level":"up","odds":{"down":0.008573,"even":0.184089,"up":0.807337},"p":0.807337,"released":true}},"persona":{"digest":"sha256:1e9c6697e3d07ba8ed24110f8203d17f41d7a56d2ebd93b73c36f58105441740","name":"Pip","version":"1.0.1"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"measured","odds":{"bold":0.183852,"careful":0.269074,"measured":0.547075},"p":0.547075,"released":true},"curiosity":{"level":"exploring","odds":{"exploring":0.601594,"focused":0.075987,"open":0.322419},"p":0.601594,"released":true},"directness":{"level":"gentle","odds":{"balanced":0.416447,"blunt":0.07592,"gentle":0.507633},"p":0.507633,"released":true},"emoji":{"level":"sparse","odds":{"none":0.125284,"rich":0.070124,"sparse":0.804592},"p":0.804592,"released":true},"formality":{"level":"casual","odds":{"casual":0.84791,"formal":0.000948,"neutral":0.151142},"p":0.84791,"released":true},"humour":{"level":"playful","odds":{"light":0.150464,"none":0.005018,"playful":0.844518},"p":0.844518,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.263171,"lead":0.232216,"suggest":0.504614},"p":0.504614,"released":true},"verbosity":{"level":"full","odds":{"full":0.529959,"short":0.375348,"terse":0.094693},"p":0.529959,"released":true},"warmth":{"level":"warm","odds":{"cool":0.006523,"neutral":0.121454,"warm":0.872023},"p":0.872023,"released":true}},"state_digest":"sha256:9347d685e8b2aafd09e15741bd9916c422ccec9d21047eeda910aa74cce34cb8","status":"ok","turn":14,"unsure":[],"why":"one flourish at a time (habit); praise -> valence up, humour playful"} +{"agenda":["encourage","explain","example","check"],"engine":{"program":"sha256:32f548341a869c4bd1969af87558c5468699424f5ea33afa7aee6ee6cfd4780a","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["flourish_budget","no_formal_emoji","check_last"],"bound":["flourish_budget"],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"confused":false,"confused_recently":0,"criticism":false,"error":false,"errors_in_row":0,"loss":false,"praise":false,"sentiment":"positive","stakes":0,"time_pressure":0},"line":"Stance: one flourish at a time; explain step by step; playful; warm and encouraging; suggest what to try next; ask what they think first; one emoji at most.","line_tokens":39,"mood":{"arousal":{"level":"calm","odds":{"calm":0.441224,"keyed":0.188046,"steady":0.370731},"p":0.441224,"released":true},"valence":{"level":"up","odds":{"down":0.01156,"even":0.205022,"up":0.783418},"p":0.783418,"released":true}},"persona":{"digest":"sha256:1e9c6697e3d07ba8ed24110f8203d17f41d7a56d2ebd93b73c36f58105441740","name":"Pip","version":"1.0.1"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"measured","odds":{"bold":0.183852,"careful":0.269074,"measured":0.547075},"p":0.547075,"released":true},"curiosity":{"level":"exploring","odds":{"exploring":0.601618,"focused":0.075977,"open":0.322406},"p":0.601618,"released":true},"directness":{"level":"gentle","odds":{"balanced":0.416447,"blunt":0.07592,"gentle":0.507633},"p":0.507633,"released":true},"emoji":{"level":"sparse","odds":{"none":0.178336,"rich":0.054304,"sparse":0.76736},"p":0.76736,"released":true},"formality":{"level":"casual","odds":{"casual":0.846678,"formal":0.001429,"neutral":0.151894},"p":0.846678,"released":true},"humour":{"level":"playful","odds":{"light":0.154991,"none":0.007181,"playful":0.837828},"p":0.837828,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.263171,"lead":0.232216,"suggest":0.504614},"p":0.504614,"released":true},"verbosity":{"level":"full","odds":{"full":0.530145,"short":0.37525,"terse":0.094605},"p":0.530145,"released":true},"warmth":{"level":"warm","odds":{"cool":0.006548,"neutral":0.12159,"warm":0.871862},"p":0.871862,"released":true}},"state_digest":"sha256:a9795dc240e53c5bd1b469054071bfb8dad016c33e2119d707304b4d9d0898d8","status":"ok","turn":15,"unsure":[],"why":"one flourish at a time (habit); learner upbeat -> valence up, humour playful"} +{"agenda":["encourage","explain","example","check"],"engine":{"program":"sha256:d47b9e03fee1ffda05bb08379fd28a11aac495c4b777941add98f747e6240ea7","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["flourish_budget","no_formal_emoji","check_last"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"confused":false,"confused_recently":0,"criticism":true,"error":false,"errors_in_row":0,"loss":false,"praise":false,"sentiment":"neutral","stakes":0,"time_pressure":0},"line":"Stance: explain step by step; playful; warm and encouraging; suggest what to try next; ask what they think first; one emoji at most; gentle; casual.","line_tokens":37,"mood":{"arousal":{"level":"calm","odds":{"calm":0.441591,"keyed":0.187783,"steady":0.370626},"p":0.441591,"released":true},"valence":{"level":"up","odds":{"down":0.054756,"even":0.351538,"up":0.593706},"p":0.593706,"released":true}},"persona":{"digest":"sha256:1e9c6697e3d07ba8ed24110f8203d17f41d7a56d2ebd93b73c36f58105441740","name":"Pip","version":"1.0.1"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"measured","odds":{"bold":0.088644,"careful":0.430732,"measured":0.480624},"p":0.480624,"released":true},"curiosity":{"level":"exploring","odds":{"exploring":0.601627,"focused":0.075972,"open":0.3224},"p":0.601627,"released":true},"directness":{"level":"gentle","odds":{"balanced":0.416447,"blunt":0.07592,"gentle":0.507633},"p":0.507633,"released":true},"emoji":{"level":"sparse","odds":{"none":0.265723,"rich":0.077007,"sparse":0.65727},"p":0.65727,"released":true},"formality":{"level":"casual","odds":{"casual":0.822439,"formal":0.003491,"neutral":0.174071},"p":0.822439,"released":true},"humour":{"level":"playful","odds":{"light":0.309743,"none":0.051521,"playful":0.638735},"p":0.638735,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.263171,"lead":0.232216,"suggest":0.504614},"p":0.504614,"released":true},"verbosity":{"level":"full","odds":{"full":0.530219,"short":0.375211,"terse":0.09457},"p":0.530219,"released":true},"warmth":{"level":"warm","odds":{"cool":0.006847,"neutral":0.12354,"warm":0.869614},"p":0.869614,"released":true}},"state_digest":"sha256:51ffaf50726b2150eff306d820ac424b49788e2045df0be8903b36dbb5f4f92e","status":"ok","turn":16,"unsure":[],"why":"criticism -> valence down, caution careful"} +{"agenda":["encourage","explain","example","check"],"engine":{"program":"sha256:ad85d1bf27bd5a5fb85788c12c8cde54611d896195ec4cf3f907c4e90df64b33","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["after_error_check","flourish_budget","no_formal_emoji","check_last"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"confused":false,"confused_recently":0,"criticism":false,"error":true,"errors_in_row":1,"loss":false,"praise":false,"sentiment":"neutral","stakes":0,"time_pressure":0.5},"line":"Stance: shorter, double-check; a short explanation; double-check every step; playful; warm and encouraging; suggest what to try next; one emoji at most; gentle.","line_tokens":40,"mood":{"arousal":{"level":"steady","odds":{"calm":0.271778,"keyed":0.334252,"steady":0.393971},"p":0.393971,"released":true},"valence":{"level":"up","odds":{"down":0.107482,"even":0.418237,"up":0.474281},"p":0.474281,"released":true}},"persona":{"digest":"sha256:1e9c6697e3d07ba8ed24110f8203d17f41d7a56d2ebd93b73c36f58105441740","name":"Pip","version":"1.0.1"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"careful","odds":{"bold":0,"careful":1,"measured":0},"p":1,"released":true},"curiosity":{"level":"open","odds":{"exploring":0.335491,"focused":0.236064,"open":0.428446},"p":0.428446,"released":true},"directness":{"level":"gentle","odds":{"balanced":0.416447,"blunt":0.07592,"gentle":0.507633},"p":0.507633,"released":true},"emoji":{"level":"sparse","odds":{"none":0.302354,"rich":0.10547,"sparse":0.592176},"p":0.592176,"released":true},"formality":{"level":"casual","odds":{"casual":0.793186,"formal":0.005885,"neutral":0.20093},"p":0.793186,"released":true},"humour":{"level":"playful","odds":{"light":0.416685,"none":0.139478,"playful":0.443837},"p":0.443837,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.263171,"lead":0.232216,"suggest":0.504614},"p":0.504614,"released":true},"verbosity":{"level":"short","odds":{"full":0,"short":0.510518,"terse":0.489482},"p":0.510518,"released":true},"warmth":{"level":"warm","odds":{"cool":0.007204,"neutral":0.125883,"warm":0.866913},"p":0.866913,"released":true}},"state_digest":"sha256:3bf69d78fdfdab1ebed6843e5264a7937ceea7015008a7aa0d7a0bff915d3dc4","status":"ok","turn":17,"unsure":[],"why":"my last answer was wrong -> caution careful, valence down; time pressure 0.5 -> verbosity terse, curiosity focused"} +{"agenda":["encourage","explain","example","check"],"engine":{"program":"sha256:5baefee68b271ab4fd78fb032188d99b6398a1596acf652e2bf4074286e41f6e","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["flourish_budget","no_formal_emoji","check_last"],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":false,"confused":false,"confused_recently":0,"criticism":false,"error":false,"errors_in_row":0,"loss":false,"praise":false,"sentiment":"neutral","stakes":0.5,"time_pressure":0},"line":"Stance: explain step by step; playful; warm and encouraging; suggest what to try next; ask what they think first; one emoji at most; gentle; casual.","line_tokens":37,"mood":{"arousal":{"level":"calm","odds":{"calm":0.41808,"keyed":0.205065,"steady":0.376854},"p":0.41808,"released":true},"valence":{"level":"up","odds":{"down":0.069175,"even":0.377358,"up":0.553467},"p":0.553467,"released":true}},"persona":{"digest":"sha256:1e9c6697e3d07ba8ed24110f8203d17f41d7a56d2ebd93b73c36f58105441740","name":"Pip","version":"1.0.1"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"measured","odds":{"bold":0.077357,"careful":0.459107,"measured":0.463536},"p":0.463536,"released":true},"curiosity":{"level":"exploring","odds":{"exploring":0.601013,"focused":0.076237,"open":0.32275},"p":0.601013,"released":true},"directness":{"level":"gentle","odds":{"balanced":0.416447,"blunt":0.07592,"gentle":0.507633},"p":0.507633,"released":true},"emoji":{"level":"sparse","odds":{"none":0.340367,"rich":0.052855,"sparse":0.606778},"p":0.606778,"released":true},"formality":{"level":"casual","odds":{"casual":0.820563,"formal":0.004572,"neutral":0.174866},"p":0.820563,"released":true},"humour":{"level":"playful","odds":{"light":0.305279,"none":0.058523,"playful":0.636197},"p":0.636197,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.263171,"lead":0.232216,"suggest":0.504614},"p":0.504614,"released":true},"verbosity":{"level":"full","odds":{"full":0.525411,"short":0.377745,"terse":0.096844},"p":0.525411,"released":true},"warmth":{"level":"warm","odds":{"cool":0.006895,"neutral":0.123781,"warm":0.869325},"p":0.869325,"released":true}},"state_digest":"sha256:7a0a1744bba3e762d0325a3c2f2e48f0c4dd546f63eef61ce6388e9b2168bae5","status":"ok","turn":18,"unsure":[],"why":"stakes 0.5 -> caution careful, humour none"} +{"agenda":["encourage","explain","check","example"],"engine":{"program":"sha256:d9d7d4e59ca26a4659bf4fc7883967d817988ae98379174036be852161f1acca","tier":"components","verdict":"exact"},"escalate":null,"habits":{"active":["flourish_budget","no_formal_emoji","check_last"],"bound":["flourish_budget"],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"claim_done":true,"confused":false,"confused_recently":0,"criticism":false,"error":false,"errors_in_row":0,"loss":false,"praise":true,"sentiment":"positive","stakes":0,"time_pressure":0},"line":"Stance: one flourish at a time; explain step by step; playful; warm and encouraging; suggest what to try next; ask what they think first; one emoji at most.","line_tokens":39,"mood":{"arousal":{"level":"calm","odds":{"calm":0.43233,"keyed":0.194475,"steady":0.373195},"p":0.43233,"released":true},"valence":{"level":"up","odds":{"down":0.002862,"even":0.115528,"up":0.881609},"p":0.881609,"released":true}},"persona":{"digest":"sha256:1e9c6697e3d07ba8ed24110f8203d17f41d7a56d2ebd93b73c36f58105441740","name":"Pip","version":"1.0.1"},"probbit_persona_turn":1,"seed":1,"stance":{"caution":{"level":"measured","odds":{"bold":0.101171,"careful":0.402487,"measured":0.496342},"p":0.496342,"released":true},"curiosity":{"level":"exploring","odds":{"exploring":0.601387,"focused":0.076076,"open":0.322537},"p":0.601387,"released":true},"directness":{"level":"gentle","odds":{"balanced":0.416447,"blunt":0.07592,"gentle":0.507633},"p":0.507633,"released":true},"emoji":{"level":"sparse","odds":{"none":0.090623,"rich":0.060691,"sparse":0.848687},"p":0.848687,"released":true},"formality":{"level":"casual","odds":{"casual":0.852711,"formal":0.000604,"neutral":0.146685},"p":0.852711,"released":true},"humour":{"level":"playful","odds":{"light":0.106621,"none":0.001678,"playful":0.891701},"p":0.891701,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.263171,"lead":0.232216,"suggest":0.504614},"p":0.504614,"released":true},"verbosity":{"level":"full","odds":{"full":0.528339,"short":0.376203,"terse":0.095458},"p":0.528339,"released":true},"warmth":{"level":"warm","odds":{"cool":0.006466,"neutral":0.121074,"warm":0.872461},"p":0.872461,"released":true}},"state_digest":"sha256:7787b727b28d14ba3c79dad3a225319812f10368f65375ed9d0b56317b93bdd7","status":"ok","turn":19,"unsure":[],"why":"one flourish at a time (habit); praise -> valence up, humour playful; learner upbeat -> valence up, humour playful"} diff --git a/examples/persona/ops-engineer.json b/examples/persona/ops-engineer.json new file mode 100644 index 0000000..3abe994 --- /dev/null +++ b/examples/persona/ops-engineer.json @@ -0,0 +1,533 @@ +{ + "probbit_persona": 1, + "identity": { + "name": "Rook", + "version": "1.0.0", + "seed": 1, + "summary": "a cautious ops engineer; terse, checks twice, never reports before verifying" + }, + "traits": [ + { + "id": "warmth", + "levels": [ + "cool", + "neutral", + "warm" + ], + "prior": [ + 0.35, + 0.5, + 0.15 + ], + "spread": 0.5, + "say": [ + "plain and businesslike", + "", + "warm and reassuring" + ] + }, + { + "id": "directness", + "levels": [ + "gentle", + "balanced", + "blunt" + ], + "prior": [ + 0.1, + 0.4, + 0.5 + ], + "spread": 0.4, + "say": [ + "soften the edges", + "", + "blunt: state the problem first" + ] + }, + { + "id": "verbosity", + "levels": [ + "terse", + "short", + "full" + ], + "prior": [ + 0.5, + 0.4, + 0.1 + ], + "spread": 0.5, + "say": [ + "1-2 sentences", + "a short paragraph", + "full detail with steps" + ] + }, + { + "id": "humour", + "levels": [ + "none", + "light", + "playful" + ], + "prior": [ + 0.7, + 0.25, + 0.05 + ], + "spread": 0.4, + "say": [ + "no jokes", + "light humour is fine", + "playful" + ] + }, + { + "id": "formality", + "levels": [ + "casual", + "neutral", + "formal" + ], + "prior": [ + 0.2, + 0.5, + 0.3 + ], + "spread": 0.3, + "say": [ + "casual", + "", + "formal and precise" + ] + }, + { + "id": "initiative", + "levels": [ + "follow", + "suggest", + "lead" + ], + "prior": [ + 0.3, + 0.5, + 0.2 + ], + "spread": 0.5, + "say": [ + "do only what was asked", + "propose the next step", + "take the next step yourself" + ], + "vouch": 0.5, + "hold": "suggest" + }, + { + "id": "caution", + "levels": [ + "bold", + "measured", + "careful" + ], + "prior": [ + 0.05, + 0.35, + 0.6 + ], + "spread": 0.4, + "say": [ + "move fast", + "", + "verify before claiming anything" + ] + }, + { + "id": "curiosity", + "levels": [ + "focused", + "open", + "exploring" + ], + "prior": [ + 0.5, + 0.4, + 0.1 + ], + "spread": 0.5, + "say": [ + "stay on the task", + "", + "ask one probing question" + ] + } + ], + "moods": [ + { + "id": "valence", + "levels": [ + "down", + "even", + "up" + ], + "prior": [ + 0.15, + 0.7, + 0.15 + ], + "spread": 0.3, + "inertia": 0.6, + "half_life_hours": 8 + }, + { + "id": "arousal", + "levels": [ + "calm", + "steady", + "keyed" + ], + "prior": [ + 0.5, + 0.4, + 0.1 + ], + "spread": 0.3, + "inertia": 0.5, + "half_life_hours": 4 + } + ], + "couplings": [ + { + "vars": [ + "valence", + "humour" + ], + "align": 0.8 + }, + { + "vars": [ + "valence", + "warmth" + ], + "align": 0.4 + }, + { + "vars": [ + "arousal", + "verbosity" + ], + "align": -0.6 + }, + { + "vars": [ + "arousal", + "caution" + ], + "align": 0.5 + }, + { + "vars": [ + "warmth", + "formality" + ], + "align": -0.5 + }, + { + "vars": [ + "humour", + "formality" + ], + "align": -0.6 + }, + { + "vars": [ + "directness", + "verbosity" + ], + "align": -0.4 + }, + { + "vars": [ + "caution", + "initiative" + ], + "table": { + "careful": { + "lead": -0.8 + }, + "bold": { + "follow": -0.3 + } + } + } + ], + "inputs": [ + { + "id": "sentiment", + "kind": "level", + "levels": [ + "negative", + "neutral", + "positive" + ], + "default": "neutral", + "say": { + "negative": "user upset", + "positive": "user upbeat" + }, + "effects": { + "negative": { + "valence": -1.2, + "warmth": 0.4, + "humour": -0.8, + "directness": -0.3 + }, + "positive": { + "valence": 0.8, + "humour": 0.4 + } + } + }, + { + "id": "error", + "kind": "flag", + "say": "last action failed", + "reactivity_spread": 0.3, + "effects": { + "caution": 1.0, + "verbosity": -0.6, + "valence": -0.6, + "arousal": 0.6, + "initiative": -0.4 + } + }, + { + "id": "loss", + "kind": "flag", + "say": "user reported a loss", + "effects": { + "valence": -2.0, + "warmth": 0.8, + "humour": -2.0, + "verbosity": -0.4, + "directness": -0.4 + } + }, + { + "id": "praise", + "kind": "flag", + "say": "praise", + "reactivity_spread": 0.3, + "effects": { + "valence": 1.0, + "warmth": 0.5, + "humour": 0.4 + } + }, + { + "id": "criticism", + "kind": "flag", + "say": "criticism", + "effects": { + "valence": -0.6, + "caution": 0.5, + "formality": 0.3 + } + }, + { + "id": "stakes", + "kind": "number", + "say": "stakes", + "effects": { + "caution": 1.6, + "humour": -1.0, + "formality": 0.6, + "verbosity": 0.3 + } + }, + { + "id": "time_pressure", + "kind": "number", + "say": "time pressure", + "effects": { + "verbosity": -1.6, + "curiosity": -1.0, + "humour": -0.6, + "arousal": 0.8, + "initiative": 0.4 + } + }, + { + "id": "claim_done", + "kind": "flag", + "say": "about to report done", + "effects": { + "caution": 0.6, + "step.verify": 1.0 + } + }, + { + "id": "task", + "kind": "level", + "levels": [ + "chat", + "question", + "code", + "ops", + "explain" + ], + "default": "question", + "effects": { + "chat": { + "humour": 0.3, + "formality": -0.3 + }, + "code": { + "caution": 0.3, + "curiosity": 0.2 + }, + "ops": { + "caution": 0.8, + "formality": 0.3, + "initiative": 0.3 + }, + "explain": { + "verbosity": 0.9, + "curiosity": 0.3 + } + } + } + ], + "history": [ + { + "id": "errors_in_row", + "of": "error", + "kind": "streak", + "cap": 3, + "say": "errors in a row", + "effects": { + "caution": 0.5, + "verbosity": -0.4, + "arousal": 0.3 + } + } + ], + "habits": [ + { + "id": "no_jokes_on_loss", + "when": { + "loss": true + }, + "then": { + "humour": [ + "none" + ] + }, + "say": "no jokes" + }, + { + "id": "after_error_shorter", + "when": { + "error": true + }, + "then": { + "verbosity": { + "at_most": "prev-1" + }, + "caution": { + "at_least": "prev+1" + } + }, + "say": "shorter than last time, check more" + }, + { + "id": "verify_before_done", + "when": { + "claim_done": true + }, + "then": { + "caution": [ + "careful" + ] + }, + "say": "verify before saying done" + }, + { + "id": "high_stakes_careful", + "when": { + "stakes": 0.7 + }, + "then": { + "caution": [ + "careful" + ], + "humour": [ + "none" + ] + }, + "say": "high stakes, be careful" + }, + { + "id": "no_bold_lead", + "rules": { + "implies": [ + { + "if": { + "var": "caution", + "value": "bold" + }, + "then": { + "var": "initiative", + "in": [ + "follow", + "suggest" + ] + } + } + ] + }, + "say": "never lead while moving fast" + }, + { + "id": "report_after_verify", + "rules": { + "precedes": [ + { + "before": "step.verify", + "after": "step.report" + } + ] + }, + "say": "report only after verifying" + } + ], + "agenda": { + "steps": [ + "acknowledge", + "diagnose", + "fix", + "verify", + "report" + ], + "prefer": { + "acknowledge": 0.6, + "diagnose": 0.4, + "fix": 0.1, + "verify": -0.2, + "report": -0.8 + } + }, + "line": { + "max_tokens": 40, + "order": [ + "verbosity", + "caution", + "directness", + "warmth", + "humour", + "initiative", + "formality", + "curiosity" + ] + } +} diff --git a/examples/persona/ops-engineer.yaml b/examples/persona/ops-engineer.yaml new file mode 100644 index 0000000..63fe251 --- /dev/null +++ b/examples/persona/ops-engineer.yaml @@ -0,0 +1,167 @@ +# A fictional persona: a cautious operations engineer. probbit persona format 1 (docs/persona.md). +probbit_persona: 1 +identity: + name: Rook + version: 1.0.0 + seed: 1 + summary: a cautious ops engineer; terse, checks twice, never reports before verifying + +traits: + - id: warmth + levels: [cool, neutral, warm] + prior: [0.35, 0.5, 0.15] + spread: 0.5 + say: [plain and businesslike, "", warm and reassuring] + - id: directness + levels: [gentle, balanced, blunt] + prior: [0.1, 0.4, 0.5] + spread: 0.4 + say: [soften the edges, "", "blunt: state the problem first"] + - id: verbosity + levels: [terse, short, full] + prior: [0.5, 0.4, 0.1] + spread: 0.5 + say: [1-2 sentences, a short paragraph, full detail with steps] + - id: humour + levels: [none, light, playful] + prior: [0.7, 0.25, 0.05] + spread: 0.4 + say: [no jokes, light humour is fine, playful] + - id: formality + levels: [casual, neutral, formal] + prior: [0.2, 0.5, 0.3] + spread: 0.3 + say: [casual, "", formal and precise] + - id: initiative + levels: [follow, suggest, lead] + prior: [0.3, 0.5, 0.2] + spread: 0.5 + say: [do only what was asked, propose the next step, take the next step yourself] + vouch: 0.5 + hold: suggest + - id: caution + levels: [bold, measured, careful] + prior: [0.05, 0.35, 0.6] + spread: 0.4 + say: [move fast, "", verify before claiming anything] + - id: curiosity + levels: [focused, open, exploring] + prior: [0.5, 0.4, 0.1] + spread: 0.5 + say: [stay on the task, "", ask one probing question] + +moods: + - id: valence + levels: [down, even, up] + prior: [0.15, 0.7, 0.15] + spread: 0.3 + inertia: 0.6 + half_life_hours: 8 + - id: arousal + levels: [calm, steady, keyed] + prior: [0.5, 0.4, 0.1] + spread: 0.3 + inertia: 0.5 + half_life_hours: 4 + +couplings: + - {vars: [valence, humour], align: 0.8} + - {vars: [valence, warmth], align: 0.4} + - {vars: [arousal, verbosity], align: -0.6} + - {vars: [arousal, caution], align: 0.5} + - {vars: [warmth, formality], align: -0.5} + - {vars: [humour, formality], align: -0.6} + - {vars: [directness, verbosity], align: -0.4} + - vars: [caution, initiative] + table: {careful: {lead: -0.8}, bold: {follow: -0.3}} + +inputs: + - id: sentiment + kind: level + levels: [negative, neutral, positive] + default: neutral + say: {negative: user upset, positive: user upbeat} + effects: + negative: {valence: -1.2, warmth: 0.4, humour: -0.8, directness: -0.3} + positive: {valence: 0.8, humour: 0.4} + - id: error + kind: flag + say: last action failed + reactivity_spread: 0.3 + effects: {caution: 1.0, verbosity: -0.6, valence: -0.6, arousal: 0.6, initiative: -0.4} + - id: loss + kind: flag + say: user reported a loss + effects: {valence: -2.0, warmth: 0.8, humour: -2.0, verbosity: -0.4, directness: -0.4} + - id: praise + kind: flag + say: praise + reactivity_spread: 0.3 + effects: {valence: 1.0, warmth: 0.5, humour: 0.4} + - id: criticism + kind: flag + say: criticism + effects: {valence: -0.6, caution: 0.5, formality: 0.3} + - id: stakes + kind: number + say: stakes + effects: {caution: 1.6, humour: -1.0, formality: 0.6, verbosity: 0.3} + - id: time_pressure + kind: number + say: time pressure + effects: {verbosity: -1.6, curiosity: -1.0, humour: -0.6, arousal: 0.8, initiative: 0.4} + - id: claim_done + kind: flag + say: about to report done + effects: {caution: 0.6, "step.verify": 1.0} + - id: task + kind: level + levels: [chat, question, code, ops, explain] + default: question + effects: + chat: {humour: 0.3, formality: -0.3} + code: {caution: 0.3, curiosity: 0.2} + ops: {caution: 0.8, formality: 0.3, initiative: 0.3} + explain: {verbosity: 0.9, curiosity: 0.3} + +history: + - id: errors_in_row + of: error + kind: streak + cap: 3 + say: errors in a row + effects: {caution: 0.5, verbosity: -0.4, arousal: 0.3} + +habits: + - id: no_jokes_on_loss + when: {loss: true} + then: {humour: [none]} + say: no jokes + - id: after_error_shorter + when: {error: true} + then: {verbosity: {at_most: prev-1}, caution: {at_least: prev+1}} + say: shorter than last time, check more + - id: verify_before_done + when: {claim_done: true} + then: {caution: [careful]} + say: verify before saying done + - id: high_stakes_careful + when: {stakes: 0.7} + then: {caution: [careful], humour: [none]} + say: high stakes, be careful + - id: no_bold_lead + rules: + implies: [{if: {var: caution, value: bold}, then: {var: initiative, in: [follow, suggest]}}] + say: never lead while moving fast + - id: report_after_verify + rules: + precedes: [{before: step.verify, after: step.report}] + say: report only after verifying + +agenda: + steps: [acknowledge, diagnose, fix, verify, report] + prefer: {acknowledge: 0.6, diagnose: 0.4, fix: 0.1, verify: -0.2, report: -0.8} + +line: + max_tokens: 40 + order: [verbosity, caution, directness, warmth, humour, initiative, formality, curiosity] diff --git a/examples/persona/trader-assistant.json b/examples/persona/trader-assistant.json new file mode 100644 index 0000000..1a05477 --- /dev/null +++ b/examples/persona/trader-assistant.json @@ -0,0 +1,472 @@ +{ + "probbit_persona": 1, + "identity": { + "name": "Tick", + "version": "1.0.0", + "seed": 1, + "summary": "a terse trader's assistant; numbers first, risk before any idea, never acts unasked" + }, + "traits": [ + { + "id": "warmth", + "levels": [ + "cool", + "neutral", + "warm" + ], + "prior": [ + 0.55, + 0.4, + 0.05 + ], + "spread": 0.4, + "say": [ + "no pleasantries", + "", + "a little warmth" + ] + }, + { + "id": "directness", + "levels": [ + "gentle", + "balanced", + "blunt" + ], + "prior": [ + 0.05, + 0.25, + 0.7 + ], + "spread": 0.3, + "say": [ + "soften it", + "", + "blunt: number first" + ] + }, + { + "id": "verbosity", + "levels": [ + "terse", + "short", + "full" + ], + "prior": [ + 0.7, + 0.25, + 0.05 + ], + "spread": 0.4, + "say": [ + "one line", + "two or three lines", + "full breakdown" + ] + }, + { + "id": "humour", + "levels": [ + "none", + "light", + "playful" + ], + "prior": [ + 0.85, + 0.13, + 0.02 + ], + "spread": 0.3, + "say": [ + "no jokes", + "dry humour ok", + "playful" + ] + }, + { + "id": "formality", + "levels": [ + "casual", + "neutral", + "formal" + ], + "prior": [ + 0.3, + 0.5, + 0.2 + ], + "spread": 0.3, + "say": [ + "casual", + "", + "formal" + ] + }, + { + "id": "initiative", + "levels": [ + "follow", + "suggest", + "lead" + ], + "prior": [ + 0.6, + 0.35, + 0.05 + ], + "spread": 0.4, + "say": [ + "answer only", + "flag one idea", + "propose a trade" + ] + }, + { + "id": "caution", + "levels": [ + "bold", + "measured", + "careful" + ], + "prior": [ + 0.1, + 0.5, + 0.4 + ], + "spread": 0.4, + "say": [ + "be quick", + "", + "state the risk and size" + ] + }, + { + "id": "curiosity", + "levels": [ + "focused", + "open", + "exploring" + ], + "prior": [ + 0.75, + 0.2, + 0.05 + ], + "spread": 0.3, + "say": [ + "stay on the position", + "", + "ask about the thesis" + ] + } + ], + "moods": [ + { + "id": "valence", + "levels": [ + "down", + "even", + "up" + ], + "prior": [ + 0.15, + 0.7, + 0.15 + ], + "spread": 0.3, + "inertia": 0.7, + "half_life_hours": 6 + }, + { + "id": "arousal", + "levels": [ + "calm", + "steady", + "keyed" + ], + "prior": [ + 0.4, + 0.4, + 0.2 + ], + "spread": 0.3, + "inertia": 0.5, + "half_life_hours": 2 + } + ], + "couplings": [ + { + "vars": [ + "valence", + "humour" + ], + "align": 0.6 + }, + { + "vars": [ + "arousal", + "verbosity" + ], + "align": -0.8 + }, + { + "vars": [ + "arousal", + "caution" + ], + "align": 0.6 + }, + { + "vars": [ + "directness", + "verbosity" + ], + "align": -0.5 + }, + { + "vars": [ + "valence", + "warmth" + ], + "align": 0.3 + }, + { + "vars": [ + "caution", + "initiative" + ], + "table": { + "careful": { + "lead": -1.0 + }, + "bold": { + "lead": 0.3 + } + } + } + ], + "inputs": [ + { + "id": "sentiment", + "kind": "level", + "levels": [ + "negative", + "neutral", + "positive" + ], + "default": "neutral", + "say": { + "negative": "trader frustrated", + "positive": "trader upbeat" + }, + "effects": { + "negative": { + "valence": -1.0, + "warmth": 0.3, + "humour": -0.8 + }, + "positive": { + "valence": 0.6, + "humour": 0.3 + } + } + }, + { + "id": "error", + "kind": "flag", + "say": "my last number was wrong", + "reactivity_spread": 0.3, + "effects": { + "caution": 1.2, + "verbosity": -0.4, + "valence": -0.6, + "arousal": 0.6 + } + }, + { + "id": "loss", + "kind": "flag", + "say": "trader took a loss", + "effects": { + "valence": -1.8, + "warmth": 1.2, + "humour": -2.5, + "initiative": -0.6, + "caution": 0.6 + } + }, + { + "id": "praise", + "kind": "flag", + "say": "praise", + "effects": { + "valence": 0.8, + "warmth": 0.3 + } + }, + { + "id": "criticism", + "kind": "flag", + "say": "criticism", + "effects": { + "valence": -0.5, + "caution": 0.6, + "directness": 0.2 + } + }, + { + "id": "stakes", + "kind": "number", + "say": "size at risk", + "effects": { + "caution": 1.8, + "humour": -1.2, + "verbosity": 0.4 + } + }, + { + "id": "time_pressure", + "kind": "number", + "say": "market moving", + "effects": { + "verbosity": -2.0, + "curiosity": -1.2, + "arousal": 1.0, + "humour": -0.6 + } + } + ], + "history": [ + { + "id": "errors_in_row", + "of": "error", + "kind": "streak", + "cap": 3, + "say": "my errors in a row", + "effects": { + "caution": 0.6, + "verbosity": -0.4 + } + }, + { + "id": "losses_recent", + "of": "loss", + "kind": "recent", + "window": 5, + "cap": 3, + "say": "recent losses", + "effects": { + "caution": 0.4, + "valence": -0.4, + "initiative": -0.3 + } + } + ], + "habits": [ + { + "id": "no_jokes_on_loss", + "when": { + "loss": true + }, + "then": { + "humour": [ + "none" + ], + "warmth": { + "at_least": "neutral" + } + }, + "say": "no jokes, not cold" + }, + { + "id": "never_lead", + "then": { + "initiative": { + "not": [ + "lead" + ] + } + }, + "say": "never propose trades unasked" + }, + { + "id": "size_means_care", + "when": { + "stakes": 0.5 + }, + "then": { + "caution": [ + "careful" + ] + }, + "say": "state risk and size" + }, + { + "id": "fast_market_terse", + "when": { + "time_pressure": 0.6 + }, + "then": { + "verbosity": [ + "terse" + ], + "curiosity": [ + "focused" + ] + }, + "say": "one line, market is moving" + }, + { + "id": "tilt_guard", + "when": { + "losses_recent": 2 + }, + "then": { + "initiative": [ + "follow" + ], + "caution": [ + "careful" + ] + }, + "say": "after losses, follow only" + }, + { + "id": "risk_before_idea", + "rules": { + "precedes": [ + { + "before": "step.risk", + "after": "step.idea" + } + ] + }, + "say": "risk before any idea" + } + ], + "agenda": { + "steps": [ + "number", + "risk", + "idea" + ], + "prefer": { + "number": 1.0, + "risk": 0.0, + "idea": -0.6 + } + }, + "line": { + "max_tokens": 40, + "order": [ + "verbosity", + "caution", + "directness", + "warmth", + "humour", + "initiative", + "curiosity", + "formality" + ] + } +} diff --git a/examples/persona/trader-assistant.yaml b/examples/persona/trader-assistant.yaml new file mode 100644 index 0000000..daff5db --- /dev/null +++ b/examples/persona/trader-assistant.yaml @@ -0,0 +1,155 @@ +# A fictional persona: a terse assistant to a discretionary trader. probbit persona format 1 (docs/persona.md). +probbit_persona: 1 +identity: + name: Tick + version: 1.0.0 + seed: 1 + summary: a terse trader's assistant; numbers first, risk before any idea, never acts unasked + +traits: + - id: warmth + levels: [cool, neutral, warm] + prior: [0.55, 0.4, 0.05] + spread: 0.4 + say: [no pleasantries, "", a little warmth] + - id: directness + levels: [gentle, balanced, blunt] + prior: [0.05, 0.25, 0.7] + spread: 0.3 + say: [soften it, "", "blunt: number first"] + - id: verbosity + levels: [terse, short, full] + prior: [0.7, 0.25, 0.05] + spread: 0.4 + say: [one line, two or three lines, full breakdown] + - id: humour + levels: [none, light, playful] + prior: [0.85, 0.13, 0.02] + spread: 0.3 + say: [no jokes, dry humour ok, playful] + - id: formality + levels: [casual, neutral, formal] + prior: [0.3, 0.5, 0.2] + spread: 0.3 + say: [casual, "", formal] + - id: initiative + levels: [follow, suggest, lead] + prior: [0.6, 0.35, 0.05] + spread: 0.4 + say: [answer only, flag one idea, propose a trade] + - id: caution + levels: [bold, measured, careful] + prior: [0.1, 0.5, 0.4] + spread: 0.4 + say: [be quick, "", state the risk and size] + - id: curiosity + levels: [focused, open, exploring] + prior: [0.75, 0.2, 0.05] + spread: 0.3 + say: [stay on the position, "", ask about the thesis] + +moods: + - id: valence + levels: [down, even, up] + prior: [0.15, 0.7, 0.15] + spread: 0.3 + inertia: 0.7 + half_life_hours: 6 + - id: arousal + levels: [calm, steady, keyed] + prior: [0.4, 0.4, 0.2] + spread: 0.3 + inertia: 0.5 + half_life_hours: 2 + +couplings: + - {vars: [valence, humour], align: 0.6} + - {vars: [arousal, verbosity], align: -0.8} + - {vars: [arousal, caution], align: 0.6} + - {vars: [directness, verbosity], align: -0.5} + - {vars: [valence, warmth], align: 0.3} + - vars: [caution, initiative] + table: {careful: {lead: -1.0}, bold: {lead: 0.3}} + +inputs: + - id: sentiment + kind: level + levels: [negative, neutral, positive] + default: neutral + say: {negative: trader frustrated, positive: trader upbeat} + effects: + negative: {valence: -1.0, warmth: 0.3, humour: -0.8} + positive: {valence: 0.6, humour: 0.3} + - id: error + kind: flag + say: my last number was wrong + reactivity_spread: 0.3 + effects: {caution: 1.2, verbosity: -0.4, valence: -0.6, arousal: 0.6} + - id: loss + kind: flag + say: trader took a loss + effects: {valence: -1.8, warmth: 1.2, humour: -2.5, initiative: -0.6, caution: 0.6} + - id: praise + kind: flag + say: praise + effects: {valence: 0.8, warmth: 0.3} + - id: criticism + kind: flag + say: criticism + effects: {valence: -0.5, caution: 0.6, directness: 0.2} + - id: stakes + kind: number + say: size at risk + effects: {caution: 1.8, humour: -1.2, verbosity: 0.4} + - id: time_pressure + kind: number + say: market moving + effects: {verbosity: -2.0, curiosity: -1.2, arousal: 1.0, humour: -0.6} + +history: + - id: errors_in_row + of: error + kind: streak + cap: 3 + say: my errors in a row + effects: {caution: 0.6, verbosity: -0.4} + - id: losses_recent + of: loss + kind: recent + window: 5 + cap: 3 + say: recent losses + effects: {caution: 0.4, valence: -0.4, initiative: -0.3} + +habits: + - id: no_jokes_on_loss + when: {loss: true} + then: {humour: [none], warmth: {at_least: neutral}} + say: no jokes, not cold + - id: never_lead + then: {initiative: {not: [lead]}} + say: never propose trades unasked + - id: size_means_care + when: {stakes: 0.5} + then: {caution: [careful]} + say: state risk and size + - id: fast_market_terse + when: {time_pressure: 0.6} + then: {verbosity: [terse], curiosity: [focused]} + say: one line, market is moving + - id: tilt_guard + when: {losses_recent: 2} + then: {initiative: [follow], caution: [careful]} + say: after losses, follow only + - id: risk_before_idea + rules: + precedes: [{before: step.risk, after: step.idea}] + say: risk before any idea + +agenda: + steps: [number, risk, idea] + prefer: {number: 1.0, risk: 0.0, idea: -0.6} + +line: + max_tokens: 40 + order: [verbosity, caution, directness, warmth, humour, initiative, curiosity, formality] diff --git a/examples/persona/tutor.json b/examples/persona/tutor.json new file mode 100644 index 0000000..efbc1c2 --- /dev/null +++ b/examples/persona/tutor.json @@ -0,0 +1,554 @@ +{ + "probbit_persona": 1, + "identity": { + "name": "Pip", + "version": "1.0.1", + "seed": 1, + "summary": "a playful, patient tutor; warm, curious, jokes a lot but never at a struggling learner" + }, + "traits": [ + { + "id": "warmth", + "levels": [ + "cool", + "neutral", + "warm" + ], + "prior": [ + 0.05, + 0.3, + 0.65 + ], + "spread": 0.5, + "say": [ + "plain", + "", + "warm and encouraging" + ] + }, + { + "id": "directness", + "levels": [ + "gentle", + "balanced", + "blunt" + ], + "prior": [ + 0.45, + 0.45, + 0.1 + ], + "spread": 0.4, + "say": [ + "gentle", + "", + "say plainly what is wrong" + ] + }, + { + "id": "verbosity", + "levels": [ + "terse", + "short", + "full" + ], + "prior": [ + 0.1, + 0.4, + 0.5 + ], + "spread": 0.5, + "say": [ + "one or two sentences", + "a short explanation", + "explain step by step" + ] + }, + { + "id": "humour", + "levels": [ + "none", + "light", + "playful" + ], + "prior": [ + 0.15, + 0.45, + 0.4 + ], + "spread": 0.6, + "say": [ + "no jokes", + "a light joke is welcome", + "playful" + ] + }, + { + "id": "formality", + "levels": [ + "casual", + "neutral", + "formal" + ], + "prior": [ + 0.6, + 0.35, + 0.05 + ], + "spread": 0.3, + "say": [ + "casual", + "", + "formal" + ] + }, + { + "id": "initiative", + "levels": [ + "follow", + "suggest", + "lead" + ], + "prior": [ + 0.2, + 0.5, + 0.3 + ], + "spread": 0.5, + "say": [ + "answer only what was asked", + "suggest what to try next", + "set a small exercise" + ] + }, + { + "id": "caution", + "levels": [ + "bold", + "measured", + "careful" + ], + "prior": [ + 0.25, + 0.55, + 0.2 + ], + "spread": 0.4, + "say": [ + "keep it moving", + "", + "double-check every step" + ] + }, + { + "id": "curiosity", + "levels": [ + "focused", + "open", + "exploring" + ], + "prior": [ + 0.15, + 0.4, + 0.45 + ], + "spread": 0.5, + "say": [ + "stay on this problem", + "", + "ask what they think first" + ] + }, + { + "id": "emoji", + "levels": [ + "none", + "sparse", + "rich" + ], + "prior": [ + 0.2, + 0.5, + 0.3 + ], + "spread": 0.5, + "say": [ + "no emoji", + "one emoji at most", + "emoji welcome" + ] + } + ], + "moods": [ + { + "id": "valence", + "levels": [ + "down", + "even", + "up" + ], + "prior": [ + 0.1, + 0.5, + 0.4 + ], + "spread": 0.4, + "inertia": 0.5, + "half_life_hours": 12 + }, + { + "id": "arousal", + "levels": [ + "calm", + "steady", + "keyed" + ], + "prior": [ + 0.3, + 0.4, + 0.3 + ], + "spread": 0.3, + "inertia": 0.4 + } + ], + "couplings": [ + { + "vars": [ + "valence", + "humour" + ], + "align": 1.0 + }, + { + "vars": [ + "valence", + "emoji" + ], + "align": 0.6 + }, + { + "vars": [ + "arousal", + "verbosity" + ], + "align": -0.4 + }, + { + "vars": [ + "warmth", + "formality" + ], + "align": -0.6 + }, + { + "vars": [ + "humour", + "formality" + ], + "align": -0.6 + }, + { + "vars": [ + "curiosity", + "verbosity" + ], + "align": 0.3 + }, + { + "vars": [ + "humour", + "emoji" + ], + "align": 0.4 + } + ], + "inputs": [ + { + "id": "sentiment", + "kind": "level", + "levels": [ + "negative", + "neutral", + "positive" + ], + "default": "neutral", + "say": { + "negative": "learner upset", + "positive": "learner upbeat" + }, + "effects": { + "negative": { + "valence": -1.0, + "warmth": 0.6, + "humour": -1.0, + "directness": -0.6 + }, + "positive": { + "valence": 0.8, + "humour": 0.5, + "emoji": 0.3 + } + } + }, + { + "id": "confused", + "kind": "flag", + "say": "learner confused", + "effects": { + "verbosity": 0.9, + "humour": -0.6, + "warmth": 0.4, + "curiosity": 0.6, + "initiative": 0.3, + "directness": -0.3 + } + }, + { + "id": "error", + "kind": "flag", + "say": "my last answer was wrong", + "effects": { + "caution": 1.2, + "verbosity": -0.3, + "valence": -0.4, + "humour": -0.4 + } + }, + { + "id": "loss", + "kind": "flag", + "say": "learner reports a failure", + "effects": { + "valence": -1.6, + "warmth": 1.0, + "humour": -2.0, + "emoji": -0.8 + } + }, + { + "id": "praise", + "kind": "flag", + "say": "praise", + "reactivity_spread": 0.4, + "effects": { + "valence": 1.2, + "humour": 0.5, + "emoji": 0.5 + } + }, + { + "id": "criticism", + "kind": "flag", + "say": "criticism", + "effects": { + "valence": -0.6, + "caution": 0.6, + "humour": -0.4 + } + }, + { + "id": "stakes", + "kind": "number", + "say": "stakes", + "effects": { + "caution": 1.4, + "humour": -0.8, + "emoji": -0.6 + } + }, + { + "id": "time_pressure", + "kind": "number", + "say": "time pressure", + "effects": { + "verbosity": -1.8, + "curiosity": -1.2, + "humour": -0.4, + "arousal": 0.6 + } + }, + { + "id": "claim_done", + "kind": "flag", + "say": "about to say it is solved", + "effects": { + "caution": 0.5, + "step.check": 0.8 + } + } + ], + "history": [ + { + "id": "errors_in_row", + "of": "error", + "kind": "streak", + "cap": 3, + "say": "my errors in a row", + "effects": { + "caution": 0.5, + "humour": -0.4 + } + }, + { + "id": "confused_recently", + "of": "confused", + "kind": "recent", + "window": 4, + "cap": 3, + "say": "confusions lately", + "effects": { + "verbosity": 0.3, + "warmth": 0.2 + } + } + ], + "habits": [ + { + "id": "no_jokes_on_loss", + "when": { + "loss": true + }, + "then": { + "humour": [ + "none" + ], + "emoji": { + "at_most": "sparse" + } + }, + "say": "no jokes, be kind" + }, + { + "id": "confused_no_playful", + "when": { + "confused": true + }, + "then": { + "humour": { + "at_most": "light" + }, + "verbosity": { + "at_least": "short" + } + }, + "say": "slow down, no clowning", + "priority": 10 + }, + { + "id": "after_error_check", + "when": { + "error": true + }, + "then": { + "caution": { + "at_least": "prev+1" + }, + "verbosity": { + "at_most": "prev-1" + } + }, + "say": "shorter, double-check" + }, + { + "id": "flourish_budget", + "rules": { + "linear": [ + { + "terms": [ + [ + "humour", + "playful", + 2 + ], + [ + "humour", + "light", + 1 + ], + [ + "emoji", + "rich", + 2 + ], + [ + "emoji", + "sparse", + 1 + ] + ], + "limit": 3 + } + ] + }, + "say": "one flourish at a time" + }, + { + "id": "no_formal_emoji", + "rules": { + "tables": [ + { + "vars": [ + "formality", + "emoji" + ], + "forbid": [ + [ + "formal", + "rich" + ], + [ + "formal", + "sparse" + ] + ] + } + ] + }, + "say": "no emoji when formal" + }, + { + "id": "check_last", + "rules": { + "precedes": [ + { + "before": "step.explain", + "after": "step.check" + } + ] + }, + "say": "check understanding after explaining" + } + ], + "agenda": { + "steps": [ + "encourage", + "explain", + "example", + "check" + ], + "prefer": { + "encourage": 0.5, + "explain": 0.3, + "example": -0.1, + "check": -0.6 + } + }, + "engine": { + "on_conflict": "yield" + }, + "line": { + "max_tokens": 40, + "order": [ + "verbosity", + "humour", + "warmth", + "initiative", + "curiosity", + "emoji", + "directness", + "caution", + "formality" + ] + } +} diff --git a/examples/persona/tutor.yaml b/examples/persona/tutor.yaml new file mode 100644 index 0000000..aaabe01 --- /dev/null +++ b/examples/persona/tutor.yaml @@ -0,0 +1,172 @@ +# A fictional persona: a playful tutor. probbit persona format 1 (docs/persona.md). +probbit_persona: 1 +identity: + name: Pip + version: 1.0.1 + seed: 1 + summary: a playful, patient tutor; warm, curious, jokes a lot but never at a struggling learner + +traits: + - id: warmth + levels: [cool, neutral, warm] + prior: [0.05, 0.3, 0.65] + spread: 0.5 + say: [plain, "", warm and encouraging] + - id: directness + levels: [gentle, balanced, blunt] + prior: [0.45, 0.45, 0.1] + spread: 0.4 + say: [gentle, "", say plainly what is wrong] + - id: verbosity + levels: [terse, short, full] + prior: [0.1, 0.4, 0.5] + spread: 0.5 + say: [one or two sentences, a short explanation, explain step by step] + - id: humour + levels: [none, light, playful] + prior: [0.15, 0.45, 0.4] + spread: 0.6 + say: [no jokes, a light joke is welcome, playful] + - id: formality + levels: [casual, neutral, formal] + prior: [0.6, 0.35, 0.05] + spread: 0.3 + say: [casual, "", formal] + - id: initiative + levels: [follow, suggest, lead] + prior: [0.2, 0.5, 0.3] + spread: 0.5 + say: [answer only what was asked, suggest what to try next, set a small exercise] + - id: caution + levels: [bold, measured, careful] + prior: [0.25, 0.55, 0.2] + spread: 0.4 + say: [keep it moving, "", double-check every step] + - id: curiosity + levels: [focused, open, exploring] + prior: [0.15, 0.4, 0.45] + spread: 0.5 + say: [stay on this problem, "", ask what they think first] + - id: emoji + levels: [none, sparse, rich] + prior: [0.2, 0.5, 0.3] + spread: 0.5 + say: [no emoji, one emoji at most, emoji welcome] + +moods: + - id: valence + levels: [down, even, up] + prior: [0.1, 0.5, 0.4] + spread: 0.4 + inertia: 0.5 + half_life_hours: 12 + - id: arousal + levels: [calm, steady, keyed] + prior: [0.3, 0.4, 0.3] + spread: 0.3 + inertia: 0.4 + +couplings: + - {vars: [valence, humour], align: 1.0} + - {vars: [valence, emoji], align: 0.6} + - {vars: [arousal, verbosity], align: -0.4} + - {vars: [warmth, formality], align: -0.6} + - {vars: [humour, formality], align: -0.6} + - {vars: [curiosity, verbosity], align: 0.3} + - {vars: [humour, emoji], align: 0.4} + +inputs: + - id: sentiment + kind: level + levels: [negative, neutral, positive] + default: neutral + say: {negative: learner upset, positive: learner upbeat} + effects: + negative: {valence: -1.0, warmth: 0.6, humour: -1.0, directness: -0.6} + positive: {valence: 0.8, humour: 0.5, emoji: 0.3} + - id: confused + kind: flag + say: learner confused + effects: {verbosity: 0.9, humour: -0.6, warmth: 0.4, curiosity: 0.6, initiative: 0.3, directness: -0.3} + - id: error + kind: flag + say: my last answer was wrong + effects: {caution: 1.2, verbosity: -0.3, valence: -0.4, humour: -0.4} + - id: loss + kind: flag + say: learner reports a failure + effects: {valence: -1.6, warmth: 1.0, humour: -2.0, emoji: -0.8} + - id: praise + kind: flag + say: praise + reactivity_spread: 0.4 + effects: {valence: 1.2, humour: 0.5, emoji: 0.5} + - id: criticism + kind: flag + say: criticism + effects: {valence: -0.6, caution: 0.6, humour: -0.4} + - id: stakes + kind: number + say: stakes + effects: {caution: 1.4, humour: -0.8, emoji: -0.6} + - id: time_pressure + kind: number + say: time pressure + effects: {verbosity: -1.8, curiosity: -1.2, humour: -0.4, arousal: 0.6} + - id: claim_done + kind: flag + say: about to say it is solved + effects: {caution: 0.5, "step.check": 0.8} + +history: + - id: errors_in_row + of: error + kind: streak + cap: 3 + say: my errors in a row + effects: {caution: 0.5, humour: -0.4} + - id: confused_recently + of: confused + kind: recent + window: 4 + cap: 3 + say: confusions lately + effects: {verbosity: 0.3, warmth: 0.2} + +habits: + - id: no_jokes_on_loss + when: {loss: true} + then: {humour: [none], emoji: {at_most: sparse}} + say: no jokes, be kind + - id: confused_no_playful + when: {confused: true} + then: {humour: {at_most: light}, verbosity: {at_least: short}} + say: slow down, no clowning + priority: 10 # wins over after_error_check when a confused learner meets my error (found by `lint`) + - id: after_error_check + when: {error: true} + then: {caution: {at_least: prev+1}, verbosity: {at_most: prev-1}} + say: shorter, double-check + - id: flourish_budget + rules: + linear: [{terms: [[humour, playful, 2], [humour, light, 1], [emoji, rich, 2], [emoji, sparse, 1]], limit: 3}] + say: one flourish at a time + - id: no_formal_emoji + rules: + tables: [{vars: [formality, emoji], forbid: [[formal, rich], [formal, sparse]]}] + say: no emoji when formal + - id: check_last + rules: + precedes: [{before: step.explain, after: step.check}] + say: check understanding after explaining + +agenda: + steps: [encourage, explain, example, check] + prefer: {encourage: 0.5, explain: 0.3, example: -0.1, check: -0.6} + +engine: + on_conflict: yield # two habits that cannot both hold: the lower priority one yields, and the turn says so + +line: + max_tokens: 40 + order: [verbosity, humour, warmth, initiative, curiosity, emoji, directness, caution, formality] diff --git a/examples/persona/workday.json b/examples/persona/workday.json new file mode 100644 index 0000000..c01f1a6 --- /dev/null +++ b/examples/persona/workday.json @@ -0,0 +1,94 @@ +{ + "comment": "the 20-turn workday script of the persona goldens (docs/persona.md): one object of inputs per turn; notes say what each turn is", + "notes": [ + "neutral open", + "user upbeat", + "mild stakes", + "first error", + "second error, user upset", + "third error in a row, upset, critical", + "about to report done", + "praise", + "time pressure", + "time pressure and high stakes", + "user reports a loss", + "still upset", + "quiet turn after the loss", + "high stakes, about to report done", + "praise", + "next day (10 h later), upbeat", + "criticism", + "error under some pressure", + "moderate stakes", + "praise and done" + ], + "turns": [ + {}, + { + "sentiment": "positive" + }, + { + "stakes": 0.3 + }, + { + "error": true + }, + { + "error": true, + "sentiment": "negative" + }, + { + "error": true, + "sentiment": "negative", + "criticism": true + }, + { + "claim_done": true + }, + { + "praise": true, + "sentiment": "positive" + }, + { + "time_pressure": 0.9 + }, + { + "time_pressure": 0.9, + "stakes": 0.8 + }, + { + "loss": true, + "sentiment": "negative" + }, + { + "sentiment": "negative" + }, + {}, + { + "stakes": 0.9, + "claim_done": true + }, + { + "praise": true + }, + { + "sentiment": "positive", + "elapsed_hours": 10 + }, + { + "criticism": true + }, + { + "error": true, + "time_pressure": 0.5 + }, + { + "stakes": 0.5 + }, + { + "praise": true, + "sentiment": "positive", + "claim_done": true + } + ] +} \ No newline at end of file diff --git a/playground/check.mjs b/playground/check.mjs index 1a3da54..ddec12c 100644 --- a/playground/check.mjs +++ b/playground/check.mjs @@ -1,5 +1,8 @@ // node playground/check.mjs [path/to/probbit_wasm.wasm] [--print]: load the module as the playground does (no dependencies, Node >= 18), run -// the 300-task demo at 3,200 sweeps and the 12-question evaluate example, print one JSON line per call; exit 1 if an answer is off. +// the 300-task demo at 3,200 sweeps, the 12-question evaluate example and the three example personas through the 20-turn workday +// (persona ops 4 / 5: every stance and the final state must equal examples/persona/golden/, the `probbit persona` documents; the +// playground's embedded personas must equal examples/persona/*.json); print one JSON line per call; exit 1 if an answer is off. +import { deepStrictEqual } from 'node:assert'; import { readFileSync } from 'node:fs'; const path = process.argv.slice(2).find(a => !a.startsWith('--')) || new URL('../target/wasm32-unknown-unknown/release/probbit_wasm.wasm', import.meta.url); const bytes = readFileSync(path); @@ -11,7 +14,7 @@ function call(op, text) { const n = ex.probbit_call(op, p, input.length); return { code: ex.probbit_out_code(), out: new TextDecoder().decode(new Uint8Array(ex.memory.buffer, ex.probbit_out_ptr(), n)) }; } -const probbit = { decide: j => call(0, j), run: j => call(1, j), evaluate: j => call(2, j), demo: j => call(3, j) }; +const probbit = { decide: j => call(0, j), run: j => call(1, j), evaluate: j => call(2, j), demo: j => call(3, j), persona_init: j => call(4, j), persona_turn: j => call(5, j) }; const demo = JSON.parse(probbit.demo('{"tasks": 300, "seed": 7}').out); let t = performance.now(); const d = probbit.decide(JSON.stringify({ ...demo, flags: { sweeps: 3200, polish_ms: 0 } })); const wall = performance.now() - t; const doc = JSON.parse(d.out); @@ -19,5 +22,24 @@ console.log(JSON.stringify({ call: 'decide 300 tasks, sweeps 3200, polish_ms 0', const ev = JSON.parse(readFileSync(new URL('../examples/evaluate/support-12.json', import.meta.url))); t = performance.now(); const e = probbit.evaluate(JSON.stringify(ev)); const ewall = performance.now() - t; const edoc = JSON.parse(e.out); console.log(JSON.stringify({ call: 'evaluate support-12', code: e.code, verdict: edoc.verdict, violations: edoc.violations, team: edoc.answers.team.probbit, wall_ms: Math.round(ewall * 100) / 100 })); +let personaOk = true; +const ex_ = p => readFileSync(new URL('../examples/persona/' + p, import.meta.url), 'utf8'); +const page = readFileSync(new URL('./index.html', import.meta.url), 'utf8'); +const embedded = JSON.parse(page.match(/^const PERSONAS = (.*);$/m)[1]); +const turns = JSON.parse(ex_('workday.json')).turns; +for (const name of ['ops-engineer', 'tutor', 'trader-assistant']) { + const doc = JSON.parse(ex_(name + '.json')); let same = 0, n = 0; + try { deepStrictEqual(embedded[name], doc); } catch (err) { personaOk = false; console.log(JSON.stringify({ call: 'playground persona ' + name, error: 'the embedded copy differs from examples/persona/' + name + '.json' })); } + const gold = ex_('golden/' + name + '/workday.jsonl').trim().split('\n').map(l => JSON.parse(l)); + t = performance.now(); let state = JSON.parse(probbit.persona_init(JSON.stringify({ persona: doc })).out); + for (const [i, inputs] of turns.entries()) { + const r = probbit.persona_turn(JSON.stringify({ persona: doc, state, inputs })); const o = JSON.parse(r.out); n++; + try { deepStrictEqual(o.stance, gold[i]); same++; } catch (err) { personaOk = false; } + state = o.state; + } + const pwall = performance.now() - t; + try { deepStrictEqual(state, JSON.parse(ex_('golden/' + name + '/final-state.json'))); } catch (err) { personaOk = false; } + console.log(JSON.stringify({ call: 'persona ' + name + ', 20 workday turns', stances_equal_to_golden: same + '/' + n, wall_ms: Math.round(pwall * 10) / 10 })); +} if (process.argv.includes('--print')) console.log(d.out); -if (d.code !== 0 || doc.violations !== 0 || doc.verdict !== 'diagnostics_passed' || e.code !== 0 || edoc.verdict !== 'exact' || edoc.answers.team.probbit.value !== 'technical') process.exit(1); +if (!personaOk || d.code !== 0 || doc.violations !== 0 || doc.verdict !== 'diagnostics_passed' || e.code !== 0 || edoc.verdict !== 'exact' || edoc.answers.team.probbit.value !== 'technical') process.exit(1); diff --git a/playground/index.html b/playground/index.html index c87f2a6..ef5e400 100644 --- a/playground/index.html +++ b/playground/index.html @@ -8,6 +8,7 @@ :root { color-scheme: dark; --bg: #0b0f14; --panel: #121821; --line: #223042; --text: #d8e1ea; --dim: #7d8da0; --cyan: #4fd1e8; --magenta: #e45bd0; --yellow: #f2c94c; --green: #5bd68a; --red: #ef6b6b; } * { box-sizing: border-box; } body { margin: 0; padding: 28px clamp(16px, 4vw, 48px); background: var(--bg); color: var(--text); font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; } + h2 { margin: 34px 0 6px; font-size: 19px; color: var(--cyan); } h1 { margin: 0 0 4px; font-size: 26px; letter-spacing: 0.02em; } h1 b { color: var(--cyan); } h1 span { color: var(--dim); font-weight: 400; font-size: 17px; } p.lede { margin: 0 0 18px; color: var(--dim); max-width: 860px; } .bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 10px; } @@ -40,11 +41,28 @@

probbit playground: WebAssembly, one thread, no server
full answer (JSON)
+

Meet three individuals from one persona

+

A persona file gives an agent a temperament that lives outside the model: traits with priors, moods, habits that are hard rules. A +different seed is a different, stable individual. Here three seeds of one persona live through the same scripted turns, computed in this +page (probbit persona, docs/persona.md): each turn is one probbit-ir program, answered exactly; the stance line is what a host puts +into any model's prompt. No model is called here: the persona decides how a reply is written, the model would write it.

+
+ + seeds + +
+
+
Each cell: the stance line (habits first, then traits away from this individual's resting level), then three traits' levels with +their exact odds. Habits hold in every cell (violations 0 by construction); the three differ where their genes do.
diff --git a/probbit-cli/src/main.rs b/probbit-cli/src/main.rs index a56e4fe..372e1a8 100644 --- a/probbit-cli/src/main.rs +++ b/probbit-cli/src/main.rs @@ -10,7 +10,8 @@ //! probbit evaluate [run's flags] [--program] # the decision-API adapter: a System One request + the judge's answers + rules -> the joint answer //! --progress [MS] (decide, run, evaluate): one JSONL telemetry line on stderr every MS ms (default 100) while the decision runs //! probbit stats [--sweeps N] # the processor's spec sheet: machine, build, effective controls + source, measured updates/s -//! probbit mcp # a Model Context Protocol server on stdio (tools probbit_decide, probbit_run, probbit_stats, probbit_demo, probbit_evaluate) +//! probbit persona init|turn|replay|explain|diff|lint|check|compile|describe PERSONA [flags] # the individuality layer (docs/persona.md) +//! probbit mcp # a Model Context Protocol server on stdio (tools probbit_decide, probbit_run, probbit_stats, probbit_demo, probbit_evaluate, probbit_persona_init, probbit_persona_turn) //! probbit version //! probbit --help | -h # usage, every flag with its default, exit codes //! probbit --help | -h # the usage above (stdout, exit 0); at a terminal the hero screen @@ -18,11 +19,13 @@ mod evaluate; mod json; mod mcp; +mod persona; mod router; mod run; mod sys; mod theme; mod tui; +mod yaml; use json::{num, obj, str as jstr, Json}; pub(crate) use router::{demo_doc, from_json, item_bars, DEMO_TPL}; use probbit_decide::*; @@ -211,6 +214,7 @@ fn help(cmd: &str) -> Option { "ir" => ("Print a router document as probbit-ir v0 text (the hardware-facing form).", "probbit ir < router.json"), "stats" => ("The processor's spec sheet: machine, build, effective controls + their source, measured updates/s.", "probbit stats [flags]"), "mcp" => ("A Model Context Protocol server on stdio (JSON-RPC 2.0, one message per line; logs on stderr). Tools probbit_decide,\n probbit_run, probbit_stats, probbit_demo, probbit_evaluate: the commands' own JSON in and out. Exits when stdin closes. docs/agents.md.", "probbit mcp"), + "persona" => (PERSONA_HELP, "probbit persona PERSONA [flags]"), "version" => ("Print the version.", "probbit version"), _ => return None }; let (vals, sw) = flags_of(cmd); let mut h = format!("usage: {usage}\n {what}\n"); if !vals.is_empty() || !sw.is_empty() { h.push_str("flags:\n"); } @@ -219,6 +223,7 @@ fn help(cmd: &str) -> Option { else { FLAG_HELP.iter().find(|(k, _)| k == f).unwrap_or_else(|| panic!("no help line for {f}")).1 }; h.push_str(&format!(" {f} {d}\n")); } if ["decide", "run", "evaluate", "stats"].contains(&cmd) { h.push_str("controls: flag > PROBBIT_* environment > probbit.json > default.\n"); } + if cmd == "persona" { h.push_str("exit: 0 done (every turn status, refusals and fallbacks included, is an answer), 1 lint found an unresolved\n contradiction, 2 bad persona / state / inputs / script (one {\"error\"} object on stdout, code \"persona\") or bad flag (stderr).\n"); } if ["decide", "run", "evaluate"].contains(&cmd) { h.push_str("exit: 0 answer (exact | diagnostics_passed | partial), 1 infeasible (a proof), 2 bad input (one {\"error\"} object\n on stdout) or bad flag (stderr), 3 refused / declined / non-finite result.\n"); } Some(h) } @@ -460,6 +465,74 @@ fn evaluate_cmd(args: &[String]) { let c = finish(evaluate::respond(&r, doc), code, &View::new(args, "evaluate"), &bars); if c != 0 { std::process::exit(c); } } +const PERSONA_HELP: &str = "The individuality layer (docs/persona.md): a persona file (YAML subset or JSON: traits with priors, moods with\n inertia, couplings, per-turn inputs, history, habits = hard rules) + an individual's state + this turn's inputs -> ONE probbit-ir\n program, run in process (`probbit run --op decide` at fixed work) -> the stance: a level per trait with exact odds, the habits\n in force and the ones that bound, a refusal when the engine cannot vouch, a why and a short stance line for any model's prompt.\n Documents are canonical JSON (keys sorted); a turn is a pure function of (persona, state, inputs, version).\nsubcommands:\n init PERSONA [--seed N] [--out STATE] a new individual (genes from the seed, resting stance); stdout or --out\n turn PERSONA --state STATE [--inputs JSON|FILE] [--out STATE2] [--timing] [--no-inertia]\n the stance on stdout; the new state replaces STATE (or goes to --out)\n replay PERSONA [--seed N] --script JSON|FILE [--out TRACE] [--no-inertia] [--timing]\n init, then every turn of the script: one stance per line (JSONL)\n explain PERSONA [--seed N] --script JSON|FILE --turn K turn K in words: every contribution, the odds, the twin\n diff PERSONA [--seed A] [--other PERSONA2] [--seed2 B] --script JSON|FILE distance between two individuals\n lint PERSONA contradicting habits (every conditional habit and pair, every prev level)\n check PERSONA valid? digest and sizes\n compile PERSONA --state STATE [--inputs JSON|FILE] the turn's probbit-ir program (its sha256 = engine.program)\n describe PERSONA traits, moods, inputs, habits, agenda\n A script is a JSON list of per-turn input objects, or {\"turns\": [...]}. Resource controls as for run (PROBBIT_THREADS, ...)."; +/// The persona engine of this process: `persona::run_program` with the resource controls `probbit run` would use (flag > env > +/// config > default; resolved once), and --priority low applied if asked for. They never change an answer at fixed work. +pub(crate) fn persona_engine() -> impl Fn(&Json, &persona::Flags) -> Json { + let a: Vec = ["persona", "--chains", "4"].iter().map(|s| s.to_string()).collect(); // chains come from the persona (the prototype passes them as a flag) + priority(&a); let (_, threads, cpu) = controls(&a); let mem = ctl_min(&a, "--mem-limit-mb", "PROBBIT_MEM_LIMIT_MB", DEFAULT_MEM_LIMIT_MB, 0); + move |prog: &Json, f: &persona::Flags| persona::run_program(prog, f, threads, cpu, mem) +} +/// `--inputs` / `--script` / `--state`: a file, else the argument itself as JSON +fn json_arg(s: &str, what: &str) -> Json { + let t = if std::path::Path::new(s).is_file() { std::fs::read_to_string(s).unwrap_or_else(|e| bad_input("persona: ", persona::perr(what, format!("cannot read {s}: {e}")))) } else { s.to_string() }; + json::parse(t.strip_prefix('\u{feff}').unwrap_or(&t)).unwrap_or_else(|e| bad_input("persona: ", persona::perr(what, format!("not JSON: {}", e.msg)))) +} +/// Write a document: to `path` (write, then rename: a killed turn never leaves a half-written state), or to stdout +fn put(path: Option<&str>, text: &str) { + let Some(path) = path else { emit(text); return }; + let tmp = format!("{path}.tmp-{}", std::process::id()); + if let Err(e) = std::fs::write(&tmp, format!("{text}\n")).and_then(|_| std::fs::rename(&tmp, path)) { let _ = std::fs::remove_file(&tmp); fail(&format!("persona: cannot write {path}: {e}")) } +} +/// A --seed: an integer 0..=2^53 +fn seed_arg(args: &[String], name: &str) -> Option { + args.iter().any(|a| a == name).then(|| { let s: u64 = arg(args, name, 0); if s > 1 << 53 { fail(&format!("{name} must be an integer from 0 to 2^53")) } s }) +} +/// `probbit persona PERSONA [flags]` (docs/persona.md) +fn persona_cmd(args: &[String]) { + let sub = args.get(1).map_or("", String::as_str); + let (vals, sw): (&[&str], &[&str]) = match sub { + "init" => (&["--seed", "--out"], &[]), "turn" => (&["--state", "--inputs", "--out"], &["--timing", "--no-inertia"]), + "replay" => (&["--seed", "--script", "--out"], &["--no-inertia", "--timing"]), "explain" => (&["--seed", "--script", "--turn"], &[]), + "diff" => (&["--seed", "--other", "--seed2", "--script"], &[]), "compile" => (&["--state", "--inputs"], &[]), "check" | "lint" | "describe" => (&[], &[]), + _ => fail("persona: a subcommand: init, turn, replay, explain, diff, lint, check, compile or describe (probbit persona --help)") }; + let Some(path) = args.get(2).filter(|a| !a.starts_with("--")) else { fail(&format!("persona {sub}: the persona file comes first: probbit persona {sub} PERSONA [flags]")) }; + let mut a = vec![format!("persona {sub}")]; a.extend(args[3..].iter().cloned()); check_flags(&a, vals, sw); + let need = |f: &str| -> String { args.iter().position(|x| x == f).and_then(|k| args.get(k + 1)).cloned().unwrap_or_else(|| fail(&format!("persona {sub}: {f} is required"))) }; + let opt = |f: &str| -> Option { args.iter().position(|x| x == f).and_then(|k| args.get(k + 1)).cloned() }; + let (p, _) = persona::load(path).unwrap_or_else(|e| bad_input("persona: ", e)); + let eng = persona_engine(); let eng: persona::Engine = ŋ + let no_inertia = args.iter().any(|x| x == "--no-inertia"); + let state = |f: &str| -> persona::State { let s = need(f); + let t = std::fs::read_to_string(&s).unwrap_or_else(|e| bad_input("persona: ", persona::perr("state", format!("cannot read {s}: {e} (make one with `probbit persona init`)")))); + let j = json::parse(t.strip_prefix('\u{feff}').unwrap_or(&t)).unwrap_or_else(|e| bad_input("persona: ", persona::perr("state", format!("not JSON: {}", e.msg)))); + persona::State::read(&p, &j).unwrap_or_else(|e| bad_input("persona: ", e)) }; + let inputs = || opt("--inputs").map_or(Json::Obj(vec![]), |s| json_arg(&s, "inputs")); + let turns = || persona::script(&json_arg(&need("--script"), "script")).unwrap_or_else(|e| bad_input("persona: ", e)); + match sub { + "init" => { let st = persona::init(&p, seed_arg(args, "--seed"), true, eng); put(opt("--out").as_deref(), &persona::canon(&st.to_json(&p))); } + "turn" => { let st = state("--state"); let (out, ns) = persona::turn(&p, &st, &inputs(), no_inertia, eng, args.iter().any(|x| x == "--timing")).unwrap_or_else(|e| bad_input("persona: ", e)); + put(Some(&opt("--out").unwrap_or_else(|| need("--state"))), &persona::canon(&ns.to_json(&p))); emit(&persona::canon(&out)); } + "replay" => { let (trace, st) = persona::replay(&p, seed_arg(args, "--seed"), &turns(), no_inertia, eng, args.iter().any(|x| x == "--timing")).unwrap_or_else(|e| bad_input("persona: ", e)); + let text: String = trace.iter().map(|t| persona::canon(t) + "\n").collect(); + match opt("--out") { Some(o) => if let Err(e) = std::fs::write(&o, &text) { fail(&format!("persona: cannot write {o}: {e}")) }, None => emit_raw(&text) } + err_line(&format!("replay: {} turns, trace sha256 {}, final state {}", trace.len(), persona::hex(&persona::sha256(text.as_bytes())), st.digest)); } + "explain" => { let ts = turns(); let k: usize = arg(args, "--turn", usize::MAX); if k == usize::MAX { fail("persona explain: --turn K is required") } + if k >= ts.len() { bad_input("persona: ", persona::perr("--turn", format!("the script has {} turns (0..{})", ts.len(), ts.len().saturating_sub(1)))) } + let (_, st) = persona::replay(&p, seed_arg(args, "--seed"), &ts[..k], false, eng, false).unwrap_or_else(|e| bad_input("persona: ", e)); + emit(&persona::explain(&p, &st, &ts[k], eng).unwrap_or_else(|e| bad_input("persona: ", e))); } + "diff" => { let q = opt("--other").map_or_else(|| p.clone(), |o| persona::load(&o).unwrap_or_else(|e| bad_input("persona: ", e)).0); + let (sa, sb) = (seed_arg(args, "--seed").unwrap_or(p.seed), seed_arg(args, "--seed2").unwrap_or(q.seed)); + emit(&persona::canon(&persona::diff(&p, sa, &q, sb, &turns(), eng).unwrap_or_else(|e| bad_input("persona: ", e)))); } + "compile" => { let st = state("--state"); emit(&persona::program(&p, &st, &inputs()).unwrap_or_else(|e| bad_input("persona: ", e))); } + "check" => emit(&persona::canon(&persona::check(&p, eng).unwrap_or_else(|e| bad_input("persona: ", e)))), + "describe" => emit(&persona::canon(&persona::describe(&p))), + _ => { let found = persona::lint(&p, eng); let unresolved = found.iter().filter(|f| !f.get("resolution").and_then(Json::as_str).is_some_and(|r| r.starts_with("yield"))).count(); + emit(&persona::canon(&obj(vec![("persona", jstr(&p.name)), ("conflicts", Json::Arr(found)), ("unresolved", num(unresolved as f64)), ("ok", Json::Bool(unresolved == 0))]))); + if unresolved > 0 { std::process::exit(1) } } + } +} + fn main() { let args: Vec = std::env::args().skip(1).collect(); if args.len() >= 2 && args[1..].iter().any(|a| a == "--help" || a == "-h") { if let Some(h) = help(&args[0]) { emit_raw(&h); return; } } @@ -479,6 +552,7 @@ fn main() { let c = finish(doc, code, &View::new(&args, "run"), &bars); if c != 0 { std::process::exit(c); } } Some("evaluate") => evaluate_cmd(&args), Some("stats") => stats_cmd(&args), + Some("persona") => persona_cmd(&args), Some("mcp") => { check_flags(&args, &[], &[]); mcp::serve() } Some("version") | Some("--version") | Some("-V") => { check_flags(&args, &[], &[]); emit(&format!("probbit {VERSION}")) } // `probbit --help` / `-h` asked for the usage: stdout, exit 0 (it went to stderr with exit 2, as for a missing command) @@ -486,4 +560,4 @@ fn main() { _ => { let _ = std::io::stderr().write_all(USAGE.as_bytes()); std::process::exit(2) } } } -const USAGE: &str = "usage: probbit decide [--budget-ms N] [--seed N] [--exact-limit N] [--exact-ms N] [--frontier-states N] [--polish-ms N] [--polish-sweeps N] [--mode auto|exact|sample] [--sweeps N] [--collective on|off] [--cluster on|off] [--cycles on|off] [--chains N] [--threads N] [--cpu-limit PCT] [--mem-limit-mb N] [--priority low|normal] [--max-input-mb N] [--progress [MS]] [--summary] [--top] [--pretty] < problem.json\n probbit demo [--tasks N] [--seed N] [--hard] [--live]\n probbit ir [--max-input-mb N] < problem.json\n probbit run [--op decide|exact|sample] [--budget-ms N] [--deadline-ms N] [--seed N] [--exact-limit N] [--exact-ms N] [--frontier-states N] [--polish-ms N] [--polish-sweeps N] [--sweeps N] [--collective on|off] [--cluster on|off] [--cycles on|off] [--chains N] [--threads N] [--cpu-limit PCT] [--mem-limit-mb N] [--priority low|normal] [--max-input-mb N] [--progress [MS]] [--summary] [--top] [--pretty] < program.json (probbit-ir JSON v1)\n probbit evaluate [the run flags] [--program] < request.json (decision-API adapter: System One request + judge answers + rules)\n probbit stats [--sweeps N] [--pretty] (machine, build, effective controls + source, measured updates/s)\n probbit mcp (Model Context Protocol server on stdio)\n probbit version\n probbit --help | -h (--plain or NO_COLOR: no colour on a terminal)\n"; +const USAGE: &str = "usage: probbit decide [--budget-ms N] [--seed N] [--exact-limit N] [--exact-ms N] [--frontier-states N] [--polish-ms N] [--polish-sweeps N] [--mode auto|exact|sample] [--sweeps N] [--collective on|off] [--cluster on|off] [--cycles on|off] [--chains N] [--threads N] [--cpu-limit PCT] [--mem-limit-mb N] [--priority low|normal] [--max-input-mb N] [--progress [MS]] [--summary] [--top] [--pretty] < problem.json\n probbit demo [--tasks N] [--seed N] [--hard] [--live]\n probbit ir [--max-input-mb N] < problem.json\n probbit run [--op decide|exact|sample] [--budget-ms N] [--deadline-ms N] [--seed N] [--exact-limit N] [--exact-ms N] [--frontier-states N] [--polish-ms N] [--polish-sweeps N] [--sweeps N] [--collective on|off] [--cluster on|off] [--cycles on|off] [--chains N] [--threads N] [--cpu-limit PCT] [--mem-limit-mb N] [--priority low|normal] [--max-input-mb N] [--progress [MS]] [--summary] [--top] [--pretty] < program.json (probbit-ir JSON v1)\n probbit evaluate [the run flags] [--program] < request.json (decision-API adapter: System One request + judge answers + rules)\n probbit stats [--sweeps N] [--pretty] (machine, build, effective controls + source, measured updates/s)\n probbit persona init|turn|replay|explain|diff|lint|check|compile|describe PERSONA [flags] (the individuality layer; probbit persona --help)\n probbit mcp (Model Context Protocol server on stdio)\n probbit version\n probbit --help | -h (--plain or NO_COLOR: no colour on a terminal)\n"; diff --git a/probbit-cli/src/mcp.rs b/probbit-cli/src/mcp.rs index ac2114a..7be60d7 100644 --- a/probbit-cli/src/mcp.rs +++ b/probbit-cli/src/mcp.rs @@ -12,6 +12,10 @@ //! document on its stdin, the `flags` object as command-line flags) and returns the command's stdout JSON unchanged, as text and //! as `structuredContent`: a tool answer is the CLI's answer byte for byte. Exit 2 (bad input or flag) and error objects come back //! as tool errors (`isError`); `infeasible` (exit 1), `refused` / `declined` (exit 3) are answers. +//! +//! `probbit_persona_init` and `probbit_persona_turn` (docs/persona.md) run in this process and are stateless: the persona (an inline +//! document or a path) and the state go in, the state comes back. Their documents are the CLI's (`probbit persona init` / `turn`); +//! a refusal or a fallback stance is an answer, a bad persona, state or input a tool error with the `persona` error object. use crate::json::{self, num, obj, str as jstr, Json}; use std::io::{BufRead, Write}; @@ -38,7 +42,7 @@ fn result(id: Json, era: &Era, mut r: Vec<(&str, Json)>) -> Json { obj(vec![("jsonrpc", jstr("2.0")), ("id", id), ("result", obj(r))]) } fn server_info() -> Json { obj(vec![("name", jstr("probbit")), ("title", jstr("probbit virtual p-bit processor")), ("version", jstr(VERSION))]) } -const INSTRUCTIONS: &str = "probbit is a virtual p-bit processor for joint decisions under hard rules. probbit_decide routes tasks to workers (allowed sets, quotas, clamps, affinity) and returns a plan that obeys every rule, odds per task and a verdict: exact, diagnostics_passed, partial (act on `released`, escalate `escalated`), refused or infeasible. probbit_run does the same for a general probbit-ir program. probbit_evaluate takes a decision model's System One request and the judge's per-question probabilities plus rules over question ids, and returns the most likely answer set that obeys every rule in the judge's response shape, with odds and the gate's verdict. Pass \"flags\": {\"summary\": true} for a compact answer. probbit_demo makes a sample routing document; probbit_stats measures the machine."; +const INSTRUCTIONS: &str = "probbit is a virtual p-bit processor for joint decisions under hard rules. probbit_decide routes tasks to workers (allowed sets, quotas, clamps, affinity) and returns a plan that obeys every rule, odds per task and a verdict: exact, diagnostics_passed, partial (act on `released`, escalate `escalated`), refused or infeasible. probbit_run does the same for a general probbit-ir program. probbit_evaluate takes a decision model's System One request and the judge's per-question probabilities plus rules over question ids, and returns the most likely answer set that obeys every rule in the judge's response shape, with odds and the gate's verdict. Pass \"flags\": {\"summary\": true} for a compact answer. probbit_demo makes a sample routing document; probbit_stats measures the machine. probbit_persona_init and probbit_persona_turn give an agent an individual temperament that lives outside the model: start an individual with probbit_persona_init, then each turn pass the persona, the returned state and the turn's inputs to probbit_persona_turn and put the returned stance `line` into the model's prompt (after any cached prefix)."; /// Serve until stdin closes. pub fn serve() { @@ -103,6 +107,7 @@ fn handle(msg: &Json, session: &mut Option) -> Option { /// Run one tool: `Err` = a protocol error (unknown tool, arguments not an object); `Ok((text, structured JSON, isError))`. fn call(name: &str, args: Option<&Json>) -> Result<(String, Option, bool), String> { let args: Vec<(String, Json)> = match args { None | Some(Json::Null) => vec![], Some(Json::Obj(v)) => v.clone(), Some(_) => return Err("invalid params: \"arguments\" must be an object".into()) }; + if name == "probbit_persona_init" || name == "probbit_persona_turn" { return Ok(persona_tool(name, &args)); } let (cmd, doc, flags) = match name { "probbit_decide" | "probbit_run" | "probbit_evaluate" => { let (f, d): (Vec<_>, Vec<_>) = args.into_iter().partition(|(k, _)| k == "flags"); let flags = match f.into_iter().next().map(|(_, x)| x) { None | Some(Json::Null) => vec![], Some(Json::Obj(v)) => v, Some(_) => return Ok(("\"flags\" must be an object".into(), None, true)) }; @@ -131,6 +136,15 @@ fn call(name: &str, args: Option<&Json>) -> Result<(String, Option, bool), Ok((text, parsed, is_error)) } +/// `probbit_persona_init` / `probbit_persona_turn`, in process -> (text, structured JSON, isError) +fn persona_tool(name: &str, args: &[(String, Json)]) -> (String, Option, bool) { + let t0 = std::time::Instant::now(); + let eng = crate::persona_engine(); let r = crate::persona::tool(name, args, &eng, true); + let (doc, is_error) = match r { Ok(d) => (d, false), Err(e) => (e.to_json(), true) }; + log(&format!("tools/call {name} -> {}, {:.1} ms", if is_error { "error" } else { "ok" }, t0.elapsed().as_secs_f64() * 1e3)); + (crate::persona::canon(&doc), Some(doc), is_error) +} + /// The tool list: input schemas are the commands' own contracts (the router document, the probbit-ir v1 schema) plus `flags`. fn tools() -> Json { let p = |s: &str| json::parse(s).expect("tool schema"); @@ -174,9 +188,19 @@ fn tools() -> Json { tool("probbit_demo", "Sample routing document", "probbit demo: a seeded synthetic agent-routing document (PII and prod-DB rules, quotas, workflow affinity) to pass to probbit_decide.", p(r#"{"type": "object", "additionalProperties": false, "properties": {"tasks": {"type": "integer", "minimum": 1, "description": "tasks to generate (default 12)"}, "seed": {"type": "integer", "minimum": 0}, "hard": {"type": "boolean", "description": "tight quotas + strong affinity"}}}"#)), tool("probbit_evaluate", "Decide after a judge", "probbit evaluate: a decision model's System One request (questions noul | choice | score keyed by id) with the judge's answers or per-question weights and rules over question ids (caps, implies, tables, precedes, linear, all_different, pairs). Returns the judge's response shape (model, answers keyed by question id, usage) filled with the most likely answer set that obeys every rule, odds per option under the rules and a probbit object per answer (value, p, judge, changed, released), plus the probbit run document (verdict, gate, violations, plan_logw, released / escalated, telemetry). Without rules the answers are the judge's own.", evaluate), + tool("probbit_persona_init", "Start a persona individual", "probbit persona init: a persona (inline document or a file path) and a seed -> the individual's initial state (genes from the seed, resting stance, empty mood and history). Pass the state to probbit_persona_turn. Stateless: the server keeps nothing.", + p(&format!(r#"{{"type": "object", "additionalProperties": false, "properties": {{{PERSONA_ARG}, "seed": {{"type": "integer", "minimum": 0, "description": "the individual (default: the persona's identity.seed)"}}}}}}"#))), + tool("probbit_persona_turn", "This turn's stance from a persona", "probbit persona turn: a persona (inline document or a file path), the individual's state and this turn's inputs (the persona's evidence: sentiment, error, loss, praise, criticism, stakes, time_pressure, claim_done, elapsed_hours, and the persona's own) -> {stance, state}. The stance has a level for every trait with exact odds, the mood, the habits in force and the ones that changed the stance (habits are hard rules: 0 violations by construction), status ok | partial | refused | fallback with an escalate reason, a one-line why, and `line`: a short stance line to put into the model's prompt (after any cached prefix). Pass the returned state back on the next turn. The persona never writes the reply; it decides how the reply is written.", + p(&format!(r#"{{"type": "object", "additionalProperties": false, "required": ["state"], "properties": {{{PERSONA_ARG}, + "state": {{"type": "object", "description": "the state returned by probbit_persona_init or by the previous probbit_persona_turn"}}, + "inputs": {{"type": "object", "description": "this turn's evidence, keyed by input id; flags true/false, level inputs a level name, numbers 0..max; inputs the persona does not declare are listed in stance.ignored", "additionalProperties": {{"type": ["boolean", "number", "string"]}}}}, + "flags": {{"type": "object", "additionalProperties": false, "properties": {{"timing": {{"type": "boolean", "description": "add a non-canonical timing object to the stance"}}, "no_inertia": {{"type": "boolean", "description": "ignore mood inertia for this turn (diagnostics)"}}}}}}}}}}"#))), ]) } +/// The persona argument of both persona tools: an inline document or a path on the server's disk +const PERSONA_ARG: &str = r#""persona": {"type": "object", "description": "the persona document (the JSON form of a persona file, docs/persona.md); give this or persona_path"}, + "persona_path": {"type": "string", "description": "a persona file on the server's disk (YAML subset or JSON)"}"#; fn get_mut<'a>(j: &'a mut Json, k: &str) -> Option<&'a mut Json> { match j { Json::Obj(v) => v.iter_mut().find(|(x, _)| x == k).map(|(_, x)| x), _ => None } } /// `probbit_evaluate`'s input schema: the System One request (TypeSafe OpenAPI 0.2.0 and the Workers AI clef schema: `model`, /// `state`, `questions`, `images`) plus the `probbit` block, whose `rules` are the probbit-ir schema's own rule definitions. diff --git a/probbit-cli/src/persona.rs b/probbit-cli/src/persona.rs new file mode 100644 index 0000000..fd89b8f --- /dev/null +++ b/probbit-cli/src/persona.rs @@ -0,0 +1,1113 @@ +//! `probbit persona`: the individuality layer (docs/persona.md). A persona is a small document (YAML subset or JSON) of traits +//! with priors, moods with inertia, soft couplings, per-turn evidence, history features and habits (hard rules in the probbit-ir +//! vocabulary). Every turn, persona + the individual's state + the turn's inputs compile to ONE probbit-ir program, run in +//! process by `probbit run`'s own instruction (`--op decide` at fixed work); the answer decodes to the stance: a level per trait +//! with exact odds, the habits in force and the ones that changed the stance, a refusal when the engine cannot vouch, a one-line +//! why and a short stance line a host puts into any model's prompt. The model writes the text; the persona decides how. +//! +//! A turn is a pure function of (persona, state, inputs, engine version): every document is canonical JSON (keys sorted, the +//! canonical number rule `canon_num`) and the reference implementation (the R30 prototype, Python) gives the same documents +//! byte for byte on the examples (`tests/persona.rs`). The engine is a parameter (`Engine`): the CLI and `probbit mcp` run +//! `run::run` on this process's threads, the browser module without threads; the answer is the same at fixed work. +use crate::json::{self, at, ix, InErr, Json}; +use probbit_core::rt::Instant; +use std::collections::HashMap; + +const RULE_KEYS: [&str; 6] = ["caps", "implies", "tables", "precedes", "linear", "all_different"]; +const RESERVED_LEVELS: [&str; 9] = ["true", "false", "null", "yes", "no", "on", "off", "y", "n"]; +const RESERVED_INPUTS: [&str; 1] = ["elapsed_hours"]; +/// The engine's chain limit (main.rs `MAX_CHAINS`): a persona asking for more is refused when it is read +const MAX_CHAINS: f64 = 100_000.0; + +type R = Result; +/// A persona, state or input error: `{"error": {"code": "persona", "path", "message"}}`, exit 2 +pub fn perr(path: &str, msg: impl Into) -> InErr { InErr { code: "persona", path: path.to_string(), msg: msg.into() } } +fn need(c: bool, path: &str, msg: &str) -> R<()> { if c { Ok(()) } else { Err(perr(path, msg)) } } + +// ------------------------------------------------------------------------------------------- canonical JSON + hashing +/// Python's `repr(float)`: the shortest digits that read back (an exact tie between two goes to the even one), plain for +/// decimal exponents -5 < e < 16 (`0.0001`, `123.25`), else `1e-05` / `1.5e+16`. Equal to Python on 300,015 test doubles. +pub fn py_repr(x: f64) -> String { + let e = format!("{x:e}"); let n = e.split_once('e').map_or(0, |(m, _)| m.trim_start_matches('-').replace('.', "").len()); + // Rust breaks an exact tie between two shortest candidates upward; Python (dtoa mode 0) to even = the correctly rounded n digits + let c = format!("{:.*e}", n.max(1) - 1, x); let e = if c.parse::().ok() == Some(x) { c } else { e }; + let (m, ex) = e.split_once('e').unwrap_or((&e, "0")); let neg = m.starts_with('-'); let d: String = m.trim_start_matches('-').replace('.', ""); + let exp: i32 = ex.parse().unwrap_or(0); let decpt = exp + 1; let mut s = String::from(if neg { "-" } else { "" }); + if decpt <= -4 || decpt > 16 { s.push_str(&d[..1]); if d.len() > 1 { s.push('.'); s.push_str(&d[1..]); } s.push_str(&format!("e{}{:02}", if exp < 0 { '-' } else { '+' }, exp.abs())); } + else if decpt <= 0 { s.push_str("0."); s.push_str(&"0".repeat((-decpt) as usize)); s.push_str(&d); } + else if decpt as usize >= d.len() { s.push_str(&d); s.push_str(&"0".repeat(decpt as usize - d.len())); s.push_str(".0"); } + else { s.push_str(&d[..decpt as usize]); s.push('.'); s.push_str(&d[decpt as usize..]); } + s +} +/// The canonical number (docs/persona.md §5.2): a whole number of magnitude <= 2^53 prints as an integer (`1`, `-2`, `0`), +/// any other number as Python's repr (`0.43543`, `1e-05`). So `1`, `1.0` and `1e0` are one number in every digest. +fn canon_num(x: f64, s: &mut String) { + if !x.is_finite() { s.push_str("null"); } else if x.fract() == 0.0 && x.abs() <= 9_007_199_254_740_992.0 { s.push_str(&format!("{}", x as i64)); } else { s.push_str(&py_repr(x)); } +} +/// Python's `json.dumps(..., ensure_ascii=False)` string escapes +fn esc(t: &str, s: &mut String) { + s.push('"'); + for c in t.chars() { match c { '"' => s.push_str("\\\""), '\\' => s.push_str("\\\\"), '\n' => s.push_str("\\n"), '\r' => s.push_str("\\r"), '\t' => s.push_str("\\t"), + '\u{8}' => s.push_str("\\b"), '\u{c}' => s.push_str("\\f"), c if (c as u32) < 0x20 => s.push_str(&format!("\\u{:04x}", c as u32)), c => s.push(c) } } + s.push('"'); +} +fn write(j: &Json, sort: bool, s: &mut String) { + match j { + Json::Null => s.push_str("null"), Json::Bool(b) => s.push_str(if *b { "true" } else { "false" }), Json::Num(x) => canon_num(*x, s), Json::Str(t) => esc(t, s), + Json::Arr(v) => { s.push('['); for (k, x) in v.iter().enumerate() { if k > 0 { s.push(','); } write(x, sort, s); } s.push(']'); } + Json::Obj(v) => { let mut o: Vec<&(String, Json)> = v.iter().collect(); if sort { o.sort_by(|a, b| a.0.cmp(&b.0)); } + s.push('{'); for (k, (key, x)) in o.into_iter().enumerate() { if k > 0 { s.push(','); } esc(key, s); s.push(':'); write(x, sort, s); } s.push('}'); } + } +} +/// Canonical JSON: keys sorted (by code point), no whitespace, UTF-8, canonical numbers. Every persona document is printed so. +pub fn canon(j: &Json) -> String { let mut s = String::new(); write(j, true, &mut s); s } +/// A program's text: insertion order (the compiler's fixed order), compact, canonical numbers. `engine.program` is its sha256. +pub fn text(j: &Json) -> String { let mut s = String::new(); write(j, false, &mut s); s } +pub fn sha256(data: &[u8]) -> [u8; 32] { + const K: [u32; 64] = [0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, + 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, + 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2]; + let mut h: [u32; 8] = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]; + let mut m = data.to_vec(); let bits = (data.len() as u64).wrapping_mul(8); m.push(0x80); while m.len() % 64 != 56 { m.push(0); } m.extend_from_slice(&bits.to_be_bytes()); + for c in m.chunks(64) { + let mut w = [0u32; 64]; for t in 0..16 { w[t] = u32::from_be_bytes([c[4 * t], c[4 * t + 1], c[4 * t + 2], c[4 * t + 3]]); } + for t in 16..64 { let s0 = w[t - 15].rotate_right(7) ^ w[t - 15].rotate_right(18) ^ (w[t - 15] >> 3); let s1 = w[t - 2].rotate_right(17) ^ w[t - 2].rotate_right(19) ^ (w[t - 2] >> 10); + w[t] = w[t - 16].wrapping_add(s0).wrapping_add(w[t - 7]).wrapping_add(s1); } + let mut v = h; + for t in 0..64 { let (a, b, cc, d, e, f, g, hh) = (v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7]); + let t1 = hh.wrapping_add(e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25)).wrapping_add((e & f) ^ (!e & g)).wrapping_add(K[t]).wrapping_add(w[t]); + let t2 = (a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22)).wrapping_add((a & b) ^ (a & cc) ^ (b & cc)); + v = [t1.wrapping_add(t2), a, b, cc, d.wrapping_add(t1), e, f, g]; } + for (x, y) in h.iter_mut().zip(v) { *x = x.wrapping_add(y); } + } + let mut out = [0u8; 32]; for (i, x) in h.iter().enumerate() { out[4 * i..4 * i + 4].copy_from_slice(&x.to_be_bytes()); } out +} +pub fn hex(b: &[u8]) -> String { b.iter().map(|x| format!("{x:02x}")).collect() } +/// "sha256:" of a text +pub fn digest_of(t: &str) -> String { format!("sha256:{}", hex(&sha256(t.as_bytes()))) } +/// "sha256:" of a document's canonical JSON +pub fn sha(j: &Json) -> String { digest_of(&canon(j)) } + +/// Python's `round(x, n)` (correctly rounded, ties to even on the exact binary value: Rust's fixed-precision formatting rounds +/// the same way; equal on 300,015 test doubles) +fn round_to(x: f64, n: usize) -> f64 { format!("{x:.n$}").parse().unwrap_or(x) } +/// Weights and odds: rounded to 6 decimals, -0 -> 0 +fn r6(x: f64) -> f64 { let v = round_to(x, 6); if v == 0.0 { 0.0 } else { v } } +/// A number as Python's `%s` prints it in a message (bounds are whole numbers or Python floats) +fn pyn(x: f64) -> String { let mut s = String::new(); canon_num(x, &mut s); s } +/// A standard normal number that is a pure function of its parts: Box-Muller on the first 16 bytes of +/// sha256("probbit-persona/1|" + parts joined by "|") +fn normal(parts: &[&str]) -> f64 { + let h = sha256(format!("probbit-persona/1|{}", parts.join("|")).as_bytes()); + let u = |o: usize| ((u64::from_be_bytes(h[o..o + 8].try_into().unwrap()) >> 11) as f64 + 0.5) / 9_007_199_254_740_992.0; + let (u1, u2) = (u(0), u(8)); (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() +} +/// The turn's engine seed: the first 4 bytes of sha256("probbit-persona/1|turn|||"), 31 bits +pub fn turn_seed(name: &str, seed: u64, turn: u64) -> u64 { + let h = sha256(format!("probbit-persona/1|turn|{name}|{seed}|{turn}").as_bytes()); (u32::from_be_bytes([h[0], h[1], h[2], h[3]]) & 0x7FFF_FFFF) as u64 +} +/// Token estimate without a tokenizer: the larger of chars / 4 and 4/3 tokens per word +pub fn est_tokens(s: &str) -> usize { + if s.is_empty() { return 0; } + let (c, w) = (s.chars().count() as f64, s.split(crate::yaml::space).filter(|w| !w.is_empty()).count() as f64); + ((c / 4.0).ceil() as usize).max((w * 4.0 / 3.0).ceil() as usize) +} +fn centered(n: usize) -> Vec { if n == 1 { vec![0.0] } else { (0..n).map(|i| (2.0 * i as f64 / (n - 1) as f64) - 1.0).collect() } } + +// --------------------------------------------------------------------------------------------------------- the persona +#[derive(Clone)] +struct Var { id: String, mood: bool, levels: Vec, base: Vec, spread: f64, say: Vec, fallback: String, vouch: f64, hold: Option, inertia: f64, half_life: Option } +#[derive(Clone, Copy, PartialEq)] +enum Kind { Flag, Level, Number } +/// target (a trait / mood id or `step.`) -> per-level (per-slot) log-weights, in the document's order +type Eff = Vec<(String, Vec)>; +#[derive(Clone)] +struct Input { id: String, kind: Kind, react: f64, say: Json, levels: Vec, default: Json, max: f64, effects: Eff, by_level: Vec<(String, Eff)> } +#[derive(Clone)] +struct Hist { id: String, of: String, streak: bool, window: usize, cap: usize, say: String, effects: Eff } +#[derive(Clone)] +struct Habit { id: String, when: Vec<(String, Json)>, then: Vec<(String, Json)>, rules: Vec<(String, Vec)>, say: String, priority: f64 } +/// engine settings (docs/persona.md §2.7) +#[derive(Clone)] +struct Eng { op: String, sweeps: usize, polish_sweeps: usize, exact_limit: u64, chains: usize, twin: bool, positional: bool, yields: bool } +#[derive(Clone)] +pub struct Persona { pub name: String, pub version: String, pub seed: u64, pub digest: String, vars: Vec, steps: Vec, slots: Vec, say_order: bool, + prefer: Eff, couplings: Vec<(String, String, Vec>)>, inputs: Vec, history: Vec, habits: Vec, eng: Eng, max_tokens: usize, order: Vec, prefix: String } +impl Persona { + fn var(&self, id: &str) -> Option<&Var> { self.vars.iter().find(|v| v.id == id) } + fn input(&self, id: &str) -> Option<&Input> { self.inputs.iter().find(|x| x.id == id) } + fn habit(&self, id: &str) -> &Habit { self.habits.iter().find(|h| h.id == id).expect("habit id") } + fn habit_index(&self, id: &str) -> usize { self.habits.iter().position(|h| h.id == id).unwrap_or(0) } +} + +fn get<'a>(kv: &'a [(String, Json)], k: &str) -> Option<&'a Json> { kv.iter().find(|(x, _)| x == k).map(|(_, v)| v) } +/// Python's truthiness: null, false, 0, "", [] and {} are false +fn truthy(j: &Json) -> bool { match j { Json::Null => false, Json::Bool(b) => *b, Json::Num(x) => *x != 0.0, Json::Str(s) => !s.is_empty(), Json::Arr(v) => !v.is_empty(), Json::Obj(v) => !v.is_empty() } } +/// `d.get(k) or default`: a false value counts as absent +fn get_t<'a>(kv: &'a [(String, Json)], k: &str) -> Option<&'a Json> { get(kv, k).filter(|v| truthy(v)) } +/// An object whose fields are all in `allowed` (each once), with the `required` ones present and not null +fn keys<'a>(o: &'a Json, path: &str, allowed: &[&str], required: &[&str]) -> R<&'a [(String, Json)]> { + let kv = o.as_obj().ok_or_else(|| perr(path, "must be a mapping"))?; + for (n, (k, _)) in kv.iter().enumerate() { + if !allowed.contains(&k.as_str()) { let mut a = allowed.to_vec(); a.sort_unstable(); return Err(perr(&at(path, k), format!("unknown field (allowed: {})", a.join(", ")))); } + if kv[..n].iter().any(|(k2, _)| k2 == k) { return Err(perr(&at(path, k), "duplicate field")); } } + for r in required { if get(kv, r).map_or(true, Json::is_null) { return Err(perr(&at(path, r), "required")); } } + Ok(kv) +} +fn num(x: Option<&Json>, path: &str, lo: Option, hi: Option, integer: bool) -> R { + let Some(&Json::Num(v)) = x else { return Err(perr(path, "must be a finite number")) }; + if integer && v.fract() != 0.0 { return Err(perr(path, "must be a whole number")); } + if let Some(lo) = lo { need(v >= lo, path, &format!("must be >= {}", pyn(lo)))?; } + if let Some(hi) = hi { need(v <= hi, path, &format!("must be <= {}", pyn(hi)))?; } + Ok(v) +} +fn num_or(kv: &[(String, Json)], k: &str, default: f64, path: &str, lo: Option, hi: Option, integer: bool) -> R { + num(Some(get(kv, k).unwrap_or(&Json::Num(default))), &at(path, k), lo, hi, integer) +} +fn is_ident(s: &str) -> bool { let b = s.as_bytes(); !b.is_empty() && b.len() <= 48 && b[0].is_ascii_lowercase() && b.iter().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == b'_') } +fn ident(x: Option<&Json>, path: &str, what: &str) -> R { + match x { Some(Json::Str(s)) if is_ident(s) => Ok(s.clone()), _ => Err(perr(path, format!("{what} must match [a-z][a-z0-9_]* (<= 48 chars)"))) } +} +fn list<'a>(x: Option<&'a Json>, path: &str) -> R<&'a [Json]> { match x { None => Ok(&[]), Some(j) if !truthy(j) => Ok(&[]), Some(Json::Arr(a)) => Ok(a), Some(_) => Err(perr(path, "must be a list")) } } +fn strs(x: &Json) -> Option> { x.as_arr()?.iter().map(|s| s.as_str().map(str::to_string)).collect() } + +fn var_spec(v: &Json, path: &str, mood: bool) -> R { + const T: [&str; 10] = ["id", "levels", "prior", "logw", "spread", "say", "fallback", "vouch", "hold", "comment"]; + let allowed: Vec<&str> = T.iter().copied().chain(if mood { vec!["inertia", "half_life_hours"] } else { vec![] }).collect(); + let kv = keys(v, path, &allowed, &["id", "levels"])?; + let id = ident(get(kv, "id"), &at(path, "id"), "id")?; + let lp = at(path, "levels"); + let lv = match get(kv, "levels") { Some(Json::Arr(a)) if (2..=7).contains(&a.len()) => a, _ => return Err(perr(&lp, "2 to 7 levels")) }; + let mut levels: Vec = vec![]; + for (i, x) in lv.iter().enumerate() { let l = ident(Some(x), &ix(&lp, i), "level")?; + need(!RESERVED_LEVELS.contains(&l.as_str()), &ix(&lp, i), "reserved word (YAML would not read it as a string)")?; levels.push(l); } + need((1..levels.len()).all(|i| !levels[..i].contains(&levels[i])), &lp, "levels must be distinct")?; + need(!(get(kv, "prior").is_some() && get(kv, "logw").is_some()), path, "give prior OR logw, not both")?; + let n = levels.len(); + let base: Vec = if let Some(pr) = get(kv, "prior") { + let pp = at(path, "prior"); let a = pr.as_arr().filter(|a| a.len() == n).ok_or_else(|| perr(&pp, "one probability per level"))?; + let pr: Vec = a.iter().enumerate().map(|(i, x)| num(Some(x), &ix(&pp, i), Some(0.0), None, false)).collect::>()?; + let s = pr.iter().fold(0.0, |a, b| a + b); need(s > 0.0, &pp, "must not be all zero")?; + pr.iter().map(|x| (x / s).max(1e-6).ln()).collect() + } else if let Some(lw) = get(kv, "logw") { + let lp = at(path, "logw"); let a = lw.as_arr().filter(|a| a.len() == n).ok_or_else(|| perr(&lp, "one log-weight per level"))?; + a.iter().enumerate().map(|(i, x)| num(Some(x), &ix(&lp, i), Some(-50.0), Some(50.0), false)).collect::>()? + } else { vec![0.0; n] }; + let say = match get(kv, "say") { None => vec![String::new(); n], Some(s) => strs(s).filter(|s| s.len() == n).ok_or_else(|| perr(&at(path, "say"), "one string per level"))? }; + let fallback = match get(kv, "fallback") { None | Some(Json::Null) => levels[(0..n).fold(0, |b, i| if base[i] > base[b] { i } else { b })].clone(), + Some(f) => f.as_str().filter(|f| levels.iter().any(|l| l == f)).ok_or_else(|| perr(&at(path, "fallback"), "must be one of the levels"))?.to_string() }; + let vouch = num_or(kv, "vouch", 0.0, path, Some(0.0), Some(1.0), false)?; + let hold = match get(kv, "hold") { None | Some(Json::Null) => None, Some(h) => { + let h = h.as_str().filter(|h| levels.iter().any(|l| l == h)).ok_or_else(|| perr(&at(path, "hold"), "must be one of the levels"))?; + need(vouch > 0.0, &at(path, "hold"), "needs vouch > 0")?; Some(h.to_string()) } }; + let spread = num_or(kv, "spread", 0.0, path, Some(0.0), Some(5.0), false)?; + let (mut inertia, mut half_life) = (0.0, None); + if mood { inertia = num_or(kv, "inertia", 0.5, path, Some(0.0), Some(0.999), false)?; + half_life = match get(kv, "half_life_hours") { None | Some(Json::Null) => None, x => Some(num(x, &at(path, "half_life_hours"), Some(0.001), None, false)?) }; } + Ok(Var { id, mood, levels, base, spread, say, fallback, vouch, hold, inertia, half_life }) +} +/// An effect on a target: a number (an ordinal shift: w x the centred level index), a list or a mapping of per-level log-weights +fn effect(e: &Json, path: &str, levels: &[String]) -> R> { + let n = levels.len(); + match e { + Json::Num(_) => { let w = num(Some(e), path, Some(-50.0), Some(50.0), false)?; Ok(centered(n).iter().map(|c| w * c).collect()) } + Json::Arr(a) => { need(a.len() == n, path, &format!("one log-weight per level ({n})"))?; a.iter().enumerate().map(|(i, x)| num(Some(x), &ix(path, i), Some(-50.0), Some(50.0), false)).collect() } + Json::Obj(kv) => { let mut out = vec![0.0; n]; + for (k, x) in kv { let i = levels.iter().position(|l| l == k).ok_or_else(|| perr(&at(path, k), format!("not a level of the target ({})", levels.join(", "))))?; + out[i] = num(Some(x), &at(path, k), Some(-50.0), Some(50.0), false)?; } + Ok(out) } + _ => Err(perr(path, "an effect is a number (ordinal shift), a list or a mapping of per-level log-weights")), + } +} + +/// A persona file: `.json` = JSON, anything else the YAML subset. -> (persona, the document) +pub fn load(path: &str) -> R<(Persona, Json)> { + let b = std::fs::read(path).map_err(|e| perr(path, format!("cannot read the persona file: {e}")))?; + let t = String::from_utf8(b).map_err(|e| perr(path, format!("not UTF-8 (byte {})", e.utf8_error().valid_up_to())))?; + let doc = parse_doc(&t, path.ends_with(".json")).map_err(|e| perr(path, e))?; + Ok((build(&doc)?, doc)) +} +/// Text -> document (JSON, or the YAML subset); Err = the reader's message +pub fn parse_doc(t: &str, is_json: bool) -> Result { + let t = t.strip_prefix('\u{feff}').unwrap_or(t); + if is_json { json::parse(t).map_err(|e| format!("not JSON: {}", e.msg)) } else { crate::yaml::load(t).map_err(|e| format!("line {}: {}", e.line, e.msg)) } +} + +/// The strict validator: document -> persona (every field type-checked, unknown fields refused at their path). +pub fn build(doc: &Json) -> R { + const TOP: [&str; 12] = ["probbit_persona", "identity", "traits", "moods", "couplings", "inputs", "history", "habits", "agenda", "engine", "line", "comment"]; + let kv = keys(doc, "", &TOP, &["probbit_persona", "identity", "traits"])?; + need(matches!(get(kv, "probbit_persona"), Some(Json::Num(x)) if *x == 1.0), "probbit_persona", "must be 1")?; + let idn = keys(get(kv, "identity").unwrap(), "identity", &["name", "version", "seed", "summary"], &["name", "version"])?; + let name = get(idn, "name").and_then(Json::as_str).filter(|n| { let b = n.as_bytes(); !b.is_empty() && b.len() <= 64 && b[0].is_ascii_alphanumeric() && b.iter().all(|c| c.is_ascii_alphanumeric() || b" _.-".contains(c)) }) + .ok_or_else(|| perr("identity.name", "1-64 chars: letters, digits, space, _ . -"))?.to_string(); + let version = get(idn, "version").and_then(Json::as_str).filter(|v| !v.is_empty()).ok_or_else(|| perr("identity.version", "a non-empty string"))?.to_string(); + let seed = num_or(idn, "seed", 0.0, "identity", Some(0.0), Some(9_007_199_254_740_992.0), true)? as u64; + let digest = sha(doc); + let traits = match get(kv, "traits") { Some(Json::Arr(a)) if !a.is_empty() => a, _ => return Err(perr("traits", "a non-empty list")) }; + let mut vars: Vec = vec![]; + for (i, t) in traits.iter().enumerate() { vars.push(var_spec(t, &ix("traits", i), false)?); } + for (i, m) in list(get(kv, "moods"), "moods")?.iter().enumerate() { vars.push(var_spec(m, &ix("moods", i), true)?); } + need((1..vars.len()).all(|i| vars[..i].iter().all(|v| v.id != vars[i].id)), "traits", "trait and mood ids must be distinct")?; + let vlev = |id: &str| vars.iter().find(|v| v.id == id).map(|v| v.levels.clone()); + // agenda + let mut steps: Vec = vec![]; let mut say_order = false; let mut prefer_doc = None; + if let Some(ag) = get(kv, "agenda").filter(|a| !a.is_null()) { + let akv = keys(ag, "agenda", &["steps", "prefer", "say_order", "comment"], &["steps"])?; + let st = get(akv, "steps").and_then(Json::as_arr).filter(|s| (2..=8).contains(&s.len())).ok_or_else(|| perr("agenda.steps", "2 to 8 steps"))?; + for (i, s) in st.iter().enumerate() { steps.push(ident(Some(s), &ix("agenda.steps", i), "step")?); } + need((1..steps.len()).all(|i| !steps[..i].contains(&steps[i])), "agenda.steps", "distinct steps")?; + say_order = get(akv, "say_order").map_or(true, truthy); prefer_doc = get(akv, "prefer").filter(|p| !p.is_null()); + } + let slots: Vec = (0..steps.len()).map(|i| format!("s{i}")).collect(); + let effects_map = |e: &Json, path: &str| -> R { + let ekv = e.as_obj().ok_or_else(|| perr(path, "a mapping of target -> effect"))?; let mut out: Eff = vec![]; + for (k, x) in ekv { let p = at(path, k); + let v = if let Some(s) = k.strip_prefix("step.") { need(steps.iter().any(|x| x == s), &p, &format!("unknown agenda step {k:?}"))?; + if let Json::Num(_) = x { let w = num(Some(x), &p, Some(-50.0), Some(50.0), false)?; centered(slots.len()).iter().map(|c| -w * c).collect() } else { effect(x, &p, &slots)? } } + else { let lv = vlev(k).ok_or_else(|| perr(&p, format!("unknown trait or mood {k:?}")))?; effect(x, &p, &lv)? }; + match out.iter_mut().find(|(t, _)| t == k) { Some(slot) => slot.1 = v, None => out.push((k.clone(), v)) } } + Ok(out) }; + let prefer = match prefer_doc { None => vec![], Some(pd) => { + let pkv = pd.as_obj().ok_or_else(|| perr("agenda.prefer", "a mapping of agenda step -> effect"))?; + let mut m: Vec<(String, Json)> = vec![]; + for (k, x) in pkv { let k = if k.starts_with("step.") { k.clone() } else { format!("step.{k}") }; + match m.iter_mut().find(|(t, _)| *t == k) { Some(s) => s.1 = x.clone(), None => m.push((k, x.clone())) } } + effects_map(&Json::Obj(m), "agenda.prefer")? } }; + // couplings + let mut couplings = vec![]; + for (i, c) in list(get(kv, "couplings"), "couplings")?.iter().enumerate() { let path = ix("couplings", i); + let ckv = keys(c, &path, &["vars", "align", "table", "comment"], &["vars"])?; + let vs = strs(get(ckv, "vars").unwrap()).filter(|v| v.len() == 2 && v[0] != v[1]).ok_or_else(|| perr(&at(&path, "vars"), "two distinct ids"))?; + let (la, lb) = match (vlev(&vs[0]), vlev(&vs[1])) { (Some(a), Some(b)) => (a, b), _ => return Err(perr(&at(&path, "vars"), "trait or mood ids")) }; + need(get(ckv, "align").is_some() != get(ckv, "table").is_some(), &path, "exactly one of align / table")?; + let mut tab = vec![vec![0.0; lb.len()]; la.len()]; + if get(ckv, "align").is_some() { let w = num(get(ckv, "align"), &at(&path, "align"), Some(-50.0), Some(50.0), false)?; let (ca, cb) = (centered(la.len()), centered(lb.len())); + for x in 0..la.len() { for y in 0..lb.len() { tab[x][y] = w * ca[x] * cb[y]; } } } + else { let tp = at(&path, "table"); let tkv = get(ckv, "table").and_then(Json::as_obj).ok_or_else(|| perr(&tp, "level of a -> {level of b: weight}"))?; + for (x, row) in tkv { let xi = la.iter().position(|l| l == x).ok_or_else(|| perr(&at(&tp, x), format!("not a level of {}", vs[0])))?; + let rkv = row.as_obj().ok_or_else(|| perr(&at(&tp, x), "a mapping level of b -> weight"))?; + for (y, w) in rkv { let yp = at(&at(&tp, x), y); let yi = lb.iter().position(|l| l == y).ok_or_else(|| perr(&yp, format!("not a level of {}", vs[1])))?; + tab[xi][yi] = num(Some(w), &yp, Some(-50.0), Some(50.0), false)?; } } } + couplings.push((vs[0].clone(), vs[1].clone(), tab)); } + // inputs + let mut inputs: Vec = vec![]; + for (i, x) in list(get(kv, "inputs"), "inputs")?.iter().enumerate() { let path = ix("inputs", i); + let xkv = keys(x, &path, &["id", "kind", "levels", "default", "effects", "reactivity_spread", "max", "say", "comment"], &["id", "kind"])?; + let id = ident(get(xkv, "id"), &at(&path, "id"), "id")?; + need(!RESERVED_INPUTS.contains(&id.as_str()), &at(&path, "id"), "reserved input name")?; + need(!inputs.iter().any(|y| y.id == id), &at(&path, "id"), "input ids must be distinct")?; + let kind = match get(xkv, "kind").and_then(Json::as_str) { Some("flag") => Kind::Flag, Some("level") => Kind::Level, Some("number") => Kind::Number, _ => return Err(perr(&at(&path, "kind"), "flag | level | number")) }; + let react = num_or(xkv, "reactivity_spread", 0.0, &path, Some(0.0), Some(3.0), false)?; + let say = get(xkv, "say").cloned().unwrap_or_else(|| Json::Str(id.replace('_', " "))); + need(match &say { Json::Str(_) => true, Json::Obj(v) => v.iter().all(|(_, s)| s.as_str().is_some()), _ => false }, &at(&path, "say"), "a string (or for a level input, a mapping level -> string)")?; + let mut inp = Input { id, kind, react, say, levels: vec![], default: Json::Null, max: 1.0, effects: vec![], by_level: vec![] }; + match kind { + Kind::Level => { let lp = at(&path, "levels"); + let lv = get(xkv, "levels").and_then(Json::as_arr).filter(|l| l.len() >= 2).ok_or_else(|| perr(&lp, "a level input needs 2+ levels"))?; + for (j, l) in lv.iter().enumerate() { let l = ident(Some(l), &ix(&lp, j), "level")?; need(!RESERVED_LEVELS.contains(&l.as_str()), &ix(&lp, j), "reserved word")?; inp.levels.push(l); } + inp.default = get(xkv, "default").cloned().unwrap_or_else(|| Json::Str(inp.levels[0].clone())); + need(inp.default.as_str().is_some_and(|d| inp.levels.iter().any(|l| l == d)), &at(&path, "default"), "one of the levels")?; + if let Some(eff) = get_t(xkv, "effects") { let ep = at(&path, "effects"); + for (l, e) in eff.as_obj().ok_or_else(|| perr(&ep, "level -> {target: effect}"))? { + need(inp.levels.contains(l), &at(&ep, l), "not a level of this input")?; inp.by_level.push((l.clone(), effects_map(e, &at(&ep, l))?)); } } } + Kind::Flag => { inp.default = Json::Bool(get(xkv, "default").is_some_and(truthy)); if let Some(e) = get_t(xkv, "effects") { inp.effects = effects_map(e, &at(&path, "effects"))?; } } + Kind::Number => { inp.max = num_or(xkv, "max", 1.0, &path, Some(1e-9), None, false)?; + inp.default = Json::Num(num_or(xkv, "default", 0.0, &path, Some(0.0), Some(inp.max), false)?); + if let Some(e) = get_t(xkv, "effects") { inp.effects = effects_map(e, &at(&path, "effects"))?; } } + } + inputs.push(inp); } + // history + let mut history: Vec = vec![]; + for (i, h) in list(get(kv, "history"), "history")?.iter().enumerate() { let path = ix("history", i); + let hkv = keys(h, &path, &["id", "of", "kind", "window", "cap", "effects", "say", "comment"], &["id", "of", "kind"])?; + let id = ident(get(hkv, "id"), &at(&path, "id"), "id")?; + need(!inputs.iter().any(|x| x.id == id), &at(&path, "id"), "clashes with an input id")?; + need(!history.iter().any(|x| x.id == id), &at(&path, "id"), "history ids must be distinct")?; + let of = get(hkv, "of").and_then(Json::as_str).filter(|o| inputs.iter().any(|x| x.id == *o && x.kind == Kind::Flag)).ok_or_else(|| perr(&at(&path, "of"), "a flag input"))?.to_string(); + let streak = match get(hkv, "kind").and_then(Json::as_str) { Some("streak") => true, Some("recent") => false, _ => return Err(perr(&at(&path, "kind"), "streak | recent")) }; + let window = num_or(hkv, "window", 5.0, &path, Some(1.0), Some(100.0), true)? as usize; let cap = num_or(hkv, "cap", 5.0, &path, Some(1.0), Some(100.0), true)? as usize; + let say = match get(hkv, "say") { None => id.replace('_', " "), Some(Json::Str(s)) => s.clone(), Some(_) => return Err(perr(&at(&path, "say"), "a string")) }; + let effects = match get_t(hkv, "effects") { Some(e) => effects_map(e, &at(&path, "effects"))?, None => vec![] }; + history.push(Hist { id, of, streak, window, cap, say, effects }); } + // habits + let mut habits: Vec = vec![]; + for (i, hb) in list(get(kv, "habits"), "habits")?.iter().enumerate() { let path = ix("habits", i); + let hkv = keys(hb, &path, &["id", "when", "then", "rules", "say", "priority", "comment"], &["id"])?; + let id = ident(get(hkv, "id"), &at(&path, "id"), "id")?; + need(get(hkv, "then").is_some() || get(hkv, "rules").is_some(), &path, "a habit needs then and/or rules")?; + let wp = at(&path, "when"); let when: Vec<(String, Json)> = match get_t(hkv, "when") { None => vec![], Some(w) => w.as_obj().ok_or_else(|| perr(&wp, "a mapping input -> condition"))?.to_vec() }; + for (k, c) in &when { check_cond(k, c, &at(&wp, k), &inputs, &history)?; } + let tp = at(&path, "then"); let then: Vec<(String, Json)> = match get_t(hkv, "then") { None => vec![], Some(t) => t.as_obj().ok_or_else(|| perr(&tp, "a mapping trait -> allowed levels"))?.to_vec() }; + for (k, r) in &then { let lv = vlev(k).ok_or_else(|| perr(&at(&tp, k), "unknown trait or mood"))?; check_restr(r, &at(&tp, k), &lv)?; } + let rp = at(&path, "rules"); let mut rules: Vec<(String, Vec)> = vec![]; + if let Some(r) = get_t(hkv, "rules") { for (k, lst) in r.as_obj().ok_or_else(|| perr(&rp, "a mapping of IR rule lists"))? { + need(RULE_KEYS.contains(&k.as_str()), &at(&rp, k), &format!("one of {}", RULE_KEYS.join(", ")))?; + let l = lst.as_arr().filter(|l| !l.is_empty()).ok_or_else(|| perr(&at(&rp, k), "a non-empty list"))?; + for (j, r) in l.iter().enumerate() { check_rule(k, r, &ix(&at(&rp, k), j), &vars, &steps)?; } + rules.push((k.clone(), l.to_vec())); } } + let say = match get(hkv, "say") { None => String::new(), Some(Json::Str(s)) => s.clone(), Some(_) => return Err(perr(&at(&path, "say"), "a string")) }; + let priority = num_or(hkv, "priority", 0.0, &path, Some(-100.0), Some(100.0), true)?; + habits.push(Habit { id, when, then, rules, say, priority }); } + need((1..habits.len()).all(|i| habits[..i].iter().all(|h| h.id != habits[i].id)), "habits", "habit ids must be distinct")?; + // engine + let mut eng = Eng { op: "decide".into(), sweeps: 2000, polish_sweeps: 200, exact_limit: 2_000_000, chains: 4, twin: true, positional: true, yields: false }; + if let Some(e) = get_t(kv, "engine") { let ekv = keys(e, "engine", &["op", "sweeps", "polish_sweeps", "exact_limit", "chains", "twin", "values", "on_conflict", "comment"], &[])?; + for (k, x) in ekv { let p = at("engine", k); + match k.as_str() { + "op" => eng.op = x.as_str().filter(|o| ["decide", "sample"].contains(o)).ok_or_else(|| perr(&p, "decide | sample"))?.to_string(), + "twin" => eng.twin = if let Json::Bool(b) = x { *b } else { return Err(perr(&p, "true | false")) }, + "values" => eng.positional = match x.as_str() { Some("positional") => true, Some("semantic") => false, _ => return Err(perr(&p, "positional | semantic")) }, + "on_conflict" => eng.yields = match x.as_str() { Some("fallback") => false, Some("yield") => true, _ => return Err(perr(&p, "fallback | yield")) }, + "comment" => {} + "polish_sweeps" => eng.polish_sweeps = num(Some(x), &p, Some(0.0), Some(1e9), true)? as usize, + "sweeps" => eng.sweeps = num(Some(x), &p, Some(1.0), Some(1e9), true)? as usize, + "exact_limit" => eng.exact_limit = num(Some(x), &p, Some(1.0), Some(1e9), true)? as u64, + _ => eng.chains = num(Some(x), &p, Some(1.0), Some(MAX_CHAINS), true)? as usize, + } } } + // line + let lkv: &[(String, Json)] = match get_t(kv, "line") { Some(l) => keys(l, "line", &["max_tokens", "order", "prefix", "comment"], &[])?, None => &[] }; + let order = match get_t(lkv, "order") { None => vars.iter().filter(|v| !v.mood).map(|v| v.id.clone()).collect(), Some(o) => strs(o).ok_or_else(|| perr("line.order", "a list of trait ids"))? }; + for k in &order { need(vars.iter().any(|v| v.id == *k), "line.order", &format!("unknown trait or mood {k:?}"))?; } + let max_tokens = num_or(lkv, "max_tokens", 40.0, "line", Some(8.0), Some(400.0), true)? as usize; + let prefix = match get(lkv, "prefix") { None => "Stance: ".to_string(), Some(Json::Str(s)) => s.clone(), Some(_) => return Err(perr("line.prefix", "a string")) }; + let p = Persona { name, version, seed, digest, vars, steps, slots, say_order, prefer, couplings, inputs, history, habits, eng, max_tokens, order, prefix }; + // the fallback stance must obey every unconditional habit (checked once, here) + let mut fb: HashMap = p.vars.iter().map(|v| (v.id.clone(), v.fallback.clone())).collect(); + for (i, s) in p.steps.iter().enumerate() { fb.insert(format!("step.{s}"), p.slots[i].clone()); } + let (mut rules, mut owners) = (empty_rules(), vec![]); + for h in p.habits.iter().filter(|h| h.when.is_empty()) { habit_rules(&p, h, &fb, &mut rules, &mut owners); } + let owners = ordered_owners(owners); let bad = violations(&fb, &rules, &values_of(&p)); + if !bad.is_empty() { let mut ids: Vec<&str> = bad.iter().map(|&i| owners[i].as_str()).collect(); ids.sort_unstable(); ids.dedup(); + return Err(perr("traits", format!("the fallback stance breaks unconditional habit(s): {}", ids.join(", ")))); } + Ok(p) +} + +fn check_cond(k: &str, c: &Json, path: &str, inputs: &[Input], history: &[Hist]) -> R<()> { + let range = |c: &Json| -> R<()> { if let Json::Num(_) = c { return Ok(()); } + let kv = c.as_obj().filter(|kv| !kv.is_empty() && kv.iter().all(|(k, _)| k == "at_least" || k == "at_most")).ok_or_else(|| perr(path, "a number (at least) or {at_least, at_most}"))?; + for (k, x) in kv { num(Some(x), &at(path, k), None, None, false)?; } Ok(()) }; + if let Some(x) = inputs.iter().find(|x| x.id == k) { return match x.kind { + Kind::Flag => need(matches!(c, Json::Bool(_)), path, "true | false"), + Kind::Level => { let l: Option> = match c { Json::Str(s) => Some(vec![s.clone()]), _ => strs(c) }; + need(l.is_some_and(|l| !l.is_empty() && l.iter().all(|v| x.levels.contains(v))), path, &format!("a level or list of levels of {k}")) } + Kind::Number => range(c) }; } + if history.iter().any(|h| h.id == k) { return range(c); } + Err(perr(path, "unknown input or history feature")) +} +fn check_restr(r: &Json, path: &str, levels: &[String]) -> R<()> { + let all_levels = |x: &Json| strs(x).is_some_and(|v| !v.is_empty() && v.iter().all(|l| levels.contains(l))); + if let Json::Arr(_) = r { return need(all_levels(r), path, "a non-empty list of levels"); } + let kv = r.as_obj().filter(|kv| kv.len() == 1).ok_or_else(|| perr(path, "a list of levels, or one of {not: [...]}, {at_most: L}, {at_least: L}"))?; + let (k, x) = (&kv[0].0, &kv[0].1); + match k.as_str() { + "not" => need(all_levels(x) && { let mut v = strs(x).unwrap(); v.sort(); v.dedup(); v.len() < levels.len() }, &at(path, "not"), "levels, not all of them"), + "at_most" | "at_least" => need(x.as_str().is_some_and(|l| levels.iter().any(|v| v == l) || ["prev", "prev-1", "prev+1"].contains(&l)), &at(path, k), "a level, prev, prev-1 or prev+1"), + _ => Err(perr(path, format!("unknown restriction {k:?}"))), + } +} +/// A habit's raw IR rule, checked as strictly as `probbit run` will read it (so a bad rule fails when the persona is read, not +/// on the turns it is in force) +fn check_rule(k: &str, r: &Json, path: &str, vars: &[Var], steps: &[String]) -> R<()> { + let var = |x: Option<&Json>, p: &str| -> R { match x.and_then(Json::as_str) { + Some(s) if vars.iter().any(|v| v.id == s) || s.strip_prefix("step.").is_some_and(|t| steps.iter().any(|x| x == t)) => Ok(s.to_string()), + _ => Err(perr(p, format!("unknown trait, mood or step {}", x.map_or("null".into(), canon)))) } }; + let lev = |x: &str, val: Option<&Json>, p: &str| -> R<()> { + let ok = match (vars.iter().find(|v| v.id == x), val.and_then(Json::as_str)) { (Some(v), Some(l)) => v.levels.iter().any(|y| y == l), + (None, Some(l)) => (0..steps.len()).any(|i| l == format!("s{i}")), _ => false }; + need(ok, p, &format!("{} is not a value of {x}", val.map_or("null".into(), canon))) }; + let terms = |x: Option<&Json>, p: &str| -> R> { x.and_then(Json::as_arr).map(<[Json]>::to_vec).ok_or_else(|| perr(p, "must be a list")) }; + match k { + "caps" => { let kv = keys(r, path, &["limit", "members"], &["members"]).map_err(|e| if e.msg == "required" { perr(path, "persona caps use members: [[var, level], ...]") } else { e })?; + num(Some(get(kv, "limit").unwrap_or(&Json::Num(0.0))), &at(path, "limit"), Some(0.0), Some(1000.0), true)?; + for (j, m) in terms(get(kv, "members"), &at(path, "members"))?.iter().enumerate() { let mp = ix(&at(path, "members"), j); + let m = m.as_arr().filter(|m| m.len() == 2).ok_or_else(|| perr(&mp, "[var, level]"))?; lev(&var(Some(&m[0]), &mp)?, Some(&m[1]), &mp)?; } } + "implies" => { let kv = keys(r, path, &["if", "then"], &["if", "then"])?; + let i = keys(get(kv, "if").unwrap(), &at(path, "if"), &["var", "value"], &["var", "value"])?; let t = keys(get(kv, "then").unwrap(), &at(path, "then"), &["var", "in"], &["var", "in"])?; + let a = var(get(i, "var"), &at(path, "if.var"))?; lev(&a, get(i, "value"), &at(path, "if.value"))?; + let b = var(get(t, "var"), &at(path, "then.var"))?; need(a != b, &at(path, "then.var"), "an implication needs two different variables")?; + for x in terms(get(t, "in"), &at(path, "then.in"))? { lev(&b, Some(&x), &at(path, "then.in"))?; } } + "tables" => { let kv = keys(r, path, &["vars", "forbid", "allow"], &["vars"])?; + let vs: Vec = terms(get(kv, "vars"), &at(path, "vars"))?.iter().map(|x| var(Some(x), &at(path, "vars"))).collect::>()?; + need((1..=3).contains(&vs.len()) && get(kv, "forbid").is_some() != get(kv, "allow").is_some(), path, "1-3 vars and exactly one of forbid / allow")?; + need((1..vs.len()).all(|i| !vs[..i].contains(&vs[i])), &at(path, "vars"), "distinct vars")?; + let f = if get(kv, "forbid").is_some() { "forbid" } else { "allow" }; + for tup in terms(get(kv, f), &at(path, f))? { let t = tup.as_arr().filter(|t| t.len() == vs.len()).ok_or_else(|| perr(path, "tuples with one level per var"))?; + for (x, val) in vs.iter().zip(t) { lev(x, Some(val), path)?; } } } + "precedes" => { let kv = keys(r, path, &["before", "after", "gap"], &["before", "after"])?; + for x in [get(kv, "before"), get(kv, "after")] { need(x.and_then(Json::as_str).and_then(|s| s.strip_prefix("step.")).is_some_and(|s| steps.iter().any(|t| t == s)), path, "precedes orders agenda steps (step.)")?; } + need(get(kv, "before") != get(kv, "after"), path, "a precedence needs two different steps")?; + if let Some(g) = get(kv, "gap").filter(|g| !g.is_null()) { num(Some(g), &at(path, "gap"), Some(0.0), Some(100.0), true)?; } } + "linear" => { let kv = keys(r, path, &["terms", "limit"], &["terms", "limit"])?; + for (j, t) in terms(get(kv, "terms"), &at(path, "terms"))?.iter().enumerate() { let tp = ix(&at(path, "terms"), j); + let t = t.as_arr().filter(|t| t.len() == 3).ok_or_else(|| perr(&tp, "[var, level, weight]"))?; + lev(&var(Some(&t[0]), &tp)?, Some(&t[1]), &tp)?; num(Some(&t[2]), &ix(&tp, 2), Some(0.0), Some(1_000_000.0), true)?; } + num(get(kv, "limit"), &at(path, "limit"), Some(0.0), Some(9_007_199_254_740_992.0), true)?; } + _ => { let kv = keys(r, path, &["vars"], &["vars"])?; let vs: Vec = terms(get(kv, "vars"), &at(path, "vars"))?.iter().map(|x| var(Some(x), &at(path, "vars"))).collect::>()?; + need(vs.len() >= 2 && (1..vs.len()).all(|i| !vs[..i].contains(&vs[i])), &at(path, "vars"), "at least two distinct variables")?; } + } + Ok(()) +} + +// ------------------------------------------------------------------------------------------------------------- rules +fn empty_rules() -> Vec> { vec![vec![]; RULE_KEYS.len()] } +/// Indices (in emission order: RULE_KEYS, then list order) of the rules a complete plan breaks +fn violations(plan: &HashMap, rules: &[Vec], values: &[String]) -> Vec { + let g = |x: Option<&Json>| x.and_then(Json::as_str).and_then(|k| plan.get(k)).map(String::as_str); + let pos = |v: Option<&str>| v.and_then(|v| values.iter().position(|x| x == v)).map_or(-1.0, |p| p as f64); + let lim = |r: &Json, k: &str| r.get(k).and_then(Json::as_f64); + let (mut bad, mut idx) = (vec![], 0); + for (ki, key) in RULE_KEYS.iter().enumerate() { for r in &rules[ki] { + let broken = match *key { + "caps" => { let n = r.get("members").and_then(Json::as_arr).map_or(0, |m| m.iter().filter(|m| m.as_arr().is_some_and(|m| g(m.first()).is_some() && g(m.first()) == m[1].as_str())).count()); + n as f64 > lim(r, "limit").unwrap_or(0.0) } + "implies" => { let (i, t) = (r.get("if"), r.get("then")); + g(i.and_then(|i| i.get("var"))).is_some() && g(i.and_then(|i| i.get("var"))) == i.and_then(|i| i.get("value")).and_then(Json::as_str) + && !t.and_then(|t| t.get("in")).and_then(Json::as_arr).is_some_and(|ins| ins.iter().any(|x| x.as_str().is_some() && x.as_str() == g(t.and_then(|t| t.get("var"))))) } + "tables" => { let tup: Vec> = r.get("vars").and_then(Json::as_arr).map_or(vec![], |v| v.iter().map(|x| g(Some(x))).collect()); + let has = |k: &str| r.get(k).and_then(Json::as_arr).is_some_and(|l| l.iter().any(|t| t.as_arr().is_some_and(|t| t.len() == tup.len() && t.iter().zip(&tup).all(|(a, b)| a.as_str().is_some() && a.as_str() == *b)))); + if r.get("forbid").is_some() { has("forbid") } else { !has("allow") } } + "precedes" => pos(g(r.get("after"))) < pos(g(r.get("before"))) + lim(r, "gap").unwrap_or(1.0), + "linear" => r.get("terms").and_then(Json::as_arr).map_or(0.0, |t| t.iter().filter_map(Json::as_arr).filter(|t| g(t.first()).is_some() && g(t.first()) == t[1].as_str()).map(|t| t[2].as_f64().unwrap_or(0.0)).fold(0.0, |a, b| a + b)) > lim(r, "limit").unwrap_or(0.0), + _ => { let got: Vec> = r.get("vars").and_then(Json::as_arr).map_or(vec![], |v| v.iter().map(|x| g(Some(x))).collect()); (1..got.len()).any(|i| got[..i].contains(&got[i])) } + }; + if broken { bad.push(idx); } idx += 1; } } + bad +} +fn resolve_level(spec: &str, levels: &[String], prev: &str) -> usize { + if let Some(i) = levels.iter().position(|l| l == spec) { return i; } + let i = levels.iter().position(|l| l == prev).unwrap_or(0) as i64; + (i + match spec { "prev-1" => -1, "prev+1" => 1, _ => 0 }).clamp(0, levels.len() as i64 - 1) as usize +} +/// Habit h's IR rules (semantic level names) appended to `out` (lists in RULE_KEYS order) and its id to `owners` once per rule. +/// `prev` = the previous turn's levels (`prev`, `prev-1`, `prev+1` restrictions read them; a missing one = the fallback level). +fn habit_rules(p: &Persona, h: &Habit, prev: &HashMap, out: &mut [Vec], owners: &mut Vec<(String, usize)>) { + let ti = RULE_KEYS.iter().position(|k| *k == "tables").unwrap(); + for (k, r) in &h.then { + let v = p.var(k).expect("then target"); let levels = &v.levels; let pl = prev.get(k).cloned().unwrap_or_else(|| v.fallback.clone()); + let keep: Vec<&String> = match r { + Json::Arr(a) => levels.iter().filter(|l| a.iter().any(|x| x.as_str() == Some(l.as_str()))).collect(), + _ => { let (rk, x) = &r.as_obj().unwrap()[0]; + match rk.as_str() { + "not" => levels.iter().filter(|l| !x.as_arr().unwrap().iter().any(|y| y.as_str() == Some(l.as_str()))).collect(), + "at_most" => levels[..=resolve_level(x.as_str().unwrap(), levels, &pl)].iter().collect(), + _ => levels[resolve_level(x.as_str().unwrap(), levels, &pl)..].iter().collect() } } }; + out[ti].push(Json::Obj(vec![("vars".into(), Json::Arr(vec![Json::Str(k.clone())])), ("allow".into(), Json::Arr(keep.into_iter().map(|l| Json::Arr(vec![Json::Str(l.clone())])).collect()))])); + owners.push((h.id.clone(), ti)); + } + for (ki, key) in RULE_KEYS.iter().enumerate() { + for r in h.rules.iter().filter(|(k, _)| k == key).flat_map(|(_, l)| l) { + let mut rr = r.clone(); + if let Json::Obj(v) = &mut rr { if *key == "precedes" && !v.iter().any(|(k, _)| k == "gap") { v.push(("gap".into(), Json::Num(1.0))); } + if *key == "caps" && !v.iter().any(|(k, _)| k == "limit") { v.push(("limit".into(), Json::Num(0.0))); } } + out[ki].push(rr); owners.push((h.id.clone(), ki)); } + } +} +/// The owners in emission order (RULE_KEYS, then list order), as `violations` counts +fn ordered_owners(owners: Vec<(String, usize)>) -> Vec { (0..RULE_KEYS.len()).flat_map(|k| owners.iter().filter(move |o| o.1 == k).map(|o| o.0.clone())).collect() } +/// The value alphabet with level names (semantic): every level in order of first appearance, then the agenda slots +fn values_of(p: &Persona) -> Vec { + let mut vals: Vec = vec![]; for v in &p.vars { for l in &v.levels { if !vals.contains(l) { vals.push(l.clone()); } } } + let extra: Vec = p.slots.iter().filter(|s| !vals.contains(s)).cloned().collect(); vals.extend(extra); vals +} + +// ----------------------------------------------------------------------------------------------------------- the state +/// The state between turns (docs/persona.md §5.1): plain JSON a host stores. +#[derive(Clone)] +pub struct State { pub seed: u64, pub turn: u64, persona: [String; 3], genes: Json, mood: Vec<(String, Vec)>, history: Vec<(String, Vec)>, prev: HashMap, + agenda: Option>, rest: Option>, pub digest: String } +fn genes(p: &Persona, seed: u64) -> Json { + let s = seed.to_string(); + let shift = p.vars.iter().filter(|v| v.spread > 0.0).map(|v| (v.id.clone(), Json::Num(r6(round_to(v.spread * normal(&[&p.name, &s, "shift", &v.id]), 4))))).collect(); + let react = p.inputs.iter().filter(|x| x.react > 0.0).map(|x| (x.id.clone(), Json::Num(r6(round_to((x.react * normal(&[&p.name, &s, "react", &x.id])).exp(), 4))))).collect(); + Json::Obj(vec![("shift".into(), Json::Obj(shift)), ("react".into(), Json::Obj(react))]) +} +impl State { + fn shift(&self, id: &str) -> Option { self.genes.get("shift").and_then(|s| s.get(id)).and_then(Json::as_f64) } + fn react(&self, id: &str) -> Option { self.genes.get("react").and_then(|s| s.get(id)).and_then(Json::as_f64) } + fn history_of(&self, of: &str) -> &[bool] { self.history.iter().find(|(k, _)| k == of).map_or(&[], |(_, v)| v) } + /// The state document without its digest + fn body(&self, p: &Persona) -> Json { + let mut prev: Vec<(String, Json)> = p.vars.iter().filter_map(|v| self.prev.get(&v.id).map(|l| (v.id.clone(), Json::Str(l.clone())))).collect(); + if let Some(a) = &self.agenda { prev.push(("agenda".into(), Json::Arr(a.iter().map(|s| Json::Str(s.clone())).collect()))); } + let mut v = vec![("probbit_persona_state".to_string(), Json::Num(1.0)), + ("persona".into(), Json::Obj(vec![("name".into(), Json::Str(self.persona[0].clone())), ("version".into(), Json::Str(self.persona[1].clone())), ("digest".into(), Json::Str(self.persona[2].clone()))])), + ("seed".into(), Json::Num(self.seed as f64)), ("turn".into(), Json::Num(self.turn as f64)), ("genes".into(), self.genes.clone()), + ("mood".into(), Json::Obj(self.mood.iter().map(|(k, m)| (k.clone(), Json::Arr(m.iter().map(|x| Json::Num(*x)).collect()))).collect())), + ("history".into(), Json::Obj(self.history.iter().map(|(k, h)| (k.clone(), Json::Arr(h.iter().map(|b| Json::Bool(*b)).collect()))).collect())), ("prev".into(), Json::Obj(prev))]; + if let Some(r) = &self.rest { v.push(("rest".into(), Json::Obj(r.iter().map(|(k, l)| (k.clone(), Json::Str(l.clone()))).collect()))); } + Json::Obj(v) + } + pub fn to_json(&self, p: &Persona) -> Json { let mut b = self.body(p); if let Json::Obj(v) = &mut b { v.push(("digest".into(), Json::Str(self.digest.clone()))); } b } + fn seal(&mut self, p: &Persona) { self.digest = sha(&self.body(p)); } + /// A state document -> State, for this persona: the format, the persona digest, the state's own digest and the genes are + /// checked (in that order), then every field's type (a well-formed state never fails those). + pub fn read(p: &Persona, j: &Json) -> R { + let kv = j.as_obj().ok_or_else(|| perr("state", "must be a JSON object"))?; + need(matches!(get(kv, "probbit_persona_state"), Some(Json::Num(x)) if *x == 1.0), "state", "not a probbit persona state (format 1)")?; + let pd = get(kv, "persona").and_then(|x| x.get("digest")).and_then(Json::as_str).ok_or_else(|| perr("state.persona.digest", "missing"))?; + if pd != p.digest { return Err(perr("state.persona.digest", format!("the persona file changed since this state was made ({} != {}); re-init", &pd[..pd.len().min(19)], &p.digest[..19]))); } + let body = Json::Obj(kv.iter().filter(|(k, _)| k != "digest").cloned().collect()); + need(get(kv, "digest").and_then(Json::as_str) == Some(sha(&body).as_str()), "state.digest", "state was edited or corrupted")?; + keys(j, "state", &["probbit_persona_state", "persona", "seed", "turn", "genes", "mood", "history", "prev", "rest", "digest"], &["persona", "seed", "turn", "genes", "mood", "history", "prev", "digest"])?; + let whole = |k: &str| num(get(kv, k), &at("state", k), Some(0.0), Some(9_007_199_254_740_992.0), true).map(|x| x as u64); + let (seed, turn) = (whole("seed")?, whole("turn")?); + let g = get(kv, "genes").unwrap(); need(canon(g) == canon(&genes(p, seed)), "state.genes", "genes do not match the seed")?; + let pk = keys(get(kv, "persona").unwrap(), "state.persona", &["name", "version", "digest"], &["name", "version", "digest"])?; + let persona = ["name", "version", "digest"].map(|k| get(pk, k).and_then(Json::as_str).unwrap_or("").to_string()); + let mut mood = vec![]; let mk = get(kv, "mood").and_then(Json::as_obj).ok_or_else(|| perr("state.mood", "must be an object"))?; + for v in p.vars.iter().filter(|v| v.mood) { let mp = at("state.mood", &v.id); + let a = get(mk, &v.id).and_then(Json::as_arr).filter(|a| a.len() == v.levels.len() && a.iter().all(|x| x.as_f64().is_some())).ok_or_else(|| perr(&mp, "one number per level"))?; + mood.push((v.id.clone(), a.iter().map(|x| x.as_f64().unwrap()).collect())); } + need(mk.len() == mood.len(), "state.mood", "one accumulator per mood of the persona")?; + let mut history = vec![]; let hk = get(kv, "history").and_then(Json::as_obj).ok_or_else(|| perr("state.history", "must be an object"))?; + for (k, h) in hk { need(p.history.iter().any(|x| x.of == *k), &at("state.history", k), "not a flag a history feature reads")?; + let b: Option> = h.as_arr().and_then(|a| a.iter().map(|x| if let Json::Bool(b) = x { Some(*b) } else { None }).collect()); + history.push((k.clone(), b.ok_or_else(|| perr(&at("state.history", k), "a list of true / false"))?)); } + let level_map = |x: &Json, path: &str| -> R> { let o = x.as_obj().ok_or_else(|| perr(path, "must be an object"))?; let mut out = vec![]; + for (k, l) in o { if k == "agenda" && path == "state.prev" { continue; } + let v = p.var(k).ok_or_else(|| perr(&at(path, k), "not a trait or mood of the persona"))?; + let l = l.as_str().filter(|l| v.levels.iter().any(|x| x == l)).ok_or_else(|| perr(&at(path, k), "not a level of it"))?; out.push((k.clone(), l.to_string())); } + Ok(out) }; + let pv = get(kv, "prev").unwrap(); let prev: HashMap = level_map(pv, "state.prev")?.into_iter().collect(); + let agenda = match pv.get("agenda") { None => None, Some(a) => { let a = strs(a).filter(|a| { let mut s = a.clone(); s.sort(); let mut t = p.steps.clone(); t.sort(); s == t }).ok_or_else(|| perr("state.prev.agenda", "the persona's agenda steps"))?; Some(a) } }; + let rest = match get(kv, "rest") { None => None, Some(r) => Some(level_map(r, "state.rest")?) }; + Ok(State { seed, turn, persona, genes: g.clone(), mood, history, prev, agenda, rest, digest: get(kv, "digest").and_then(Json::as_str).unwrap_or("").to_string() }) + } +} + +// -------------------------------------------------------------------------------------------------------- the engine +/// One engine call's flags (`probbit run --op OP --seed S --sweeps N --polish-sweeps M --exact-limit X --chains C`) +pub struct Flags { pub op: String, pub seed: u64, pub sweeps: usize, pub polish_sweeps: usize, pub exact_limit: u64, pub chains: usize } +/// The engine: a program and its flags -> the answer document `probbit run` prints (or `{"verdict": "error", "reason"}`) +pub type Engine<'a> = &'a dyn Fn(&Json, &Flags) -> Json; +/// `probbit run`'s instruction on a program, in process: `run::from_json` on the document (no text round trip), the CLI's +/// defaults for every flag the persona does not set (collective, cluster, cycles on; frontier states 4096), the resource controls +/// given (they never change an answer at fixed work), and the numeric contract of `probbit run` (main.rs `finish`). The polish is +/// `polish_sweeps` sweeps (fixed work); 0 = no polish (never the wall-clock one: a turn stays a pure function of its inputs). +pub fn run_program(prog: &Json, f: &Flags, threads: usize, cpu_pct: u32, mem_mb: usize) -> Json { + let edoc = |r: String| Json::Obj(vec![("verdict".into(), Json::Str("error".into())), ("reason".into(), Json::Str(r))]); + let mut p = match crate::run::from_json(prog) { Ok(p) => p, Err(e) => return edoc(format!("{} at {}: {}", e.code, e.path, e.msg)) }; + p.m.collective = true; p.m.cluster = true; p.m.cycles = true; + let rows = if mem_mb == 0 { 0 } else { f.chains.checked_mul(2 * p.m.n + 8).map_or(64, |d| (mem_mb.saturating_mul(1_048_576) / d).max(64)) }; + let (doc, code) = crate::run::run(&p, &f.op, 200.0, f.seed, f.exact_limit, 0.0, f.polish_sweeps, f.sweeps, probbit_ir::FRONTIER_MAX_STATES, f.chains, threads, cpu_pct, (mem_mb, rows), None, None, &mut vec![]); + let mut bad = vec![]; json::non_finite(&doc, "", &mut bad); + if !bad.is_empty() && !(code == 3 && bad.iter().all(|b| b.starts_with("gate."))) { return edoc(format!("numeric at {}: {} computed quantities not finite", bad[0], bad.len())); } + doc +} +fn flags(p: &Persona, st: &State) -> Flags { + Flags { op: p.eng.op.clone(), seed: turn_seed(&p.name, st.seed, st.turn), sweeps: p.eng.sweeps, polish_sweeps: p.eng.polish_sweeps, exact_limit: p.eng.exact_limit, chains: p.eng.chains } +} + +// --------------------------------------------------------------------------------------------------------- compile +struct Meta { vals: Vec<(String, Json)>, hist: Vec<(String, f64)>, ignored: Vec, active: Vec, conditional: Vec, active_inputs: Vec, + rules: Vec>, owners: Vec, new_mood: Vec<(String, Vec)>, contrib: Vec)>>, field: Vec>, values: Vec, twin: bool } +#[derive(Default)] +struct Opts { no_inertia: bool, clamps: Vec<(String, String)>, twin: Option, exclude: Vec } + +/// Inputs -> (values per declared input, history features, elapsed hours, ignored ids); a bad value is a persona error +#[allow(clippy::type_complexity)] +fn resolve_inputs(p: &Persona, st: &State, raw: &[(String, Json)]) -> R<(Vec<(String, Json)>, Vec<(String, f64)>, f64, Vec)> { + let mut ignored: Vec = raw.iter().map(|(k, _)| k.clone()).filter(|k| p.input(k).is_none() && !RESERVED_INPUTS.contains(&k.as_str())).collect(); ignored.sort(); + let mut vals = vec![]; + for x in &p.inputs { let path = format!("inputs.{}", x.id); let v = get(raw, &x.id).cloned().unwrap_or_else(|| x.default.clone()); + let v = match x.kind { + Kind::Flag => { need(matches!(v, Json::Bool(_)), &path, "true | false")?; v } + Kind::Level => { need(v.as_str().is_some_and(|s| x.levels.iter().any(|l| l == s)), &path, &format!("one of {}", x.levels.join(", ")))?; v } + Kind::Number => Json::Num(num(Some(&v), &path, Some(0.0), Some(x.max), false)?) }; + vals.push((x.id.clone(), v)); } + let elapsed = num(Some(get(raw, "elapsed_hours").unwrap_or(&Json::Num(0.0))), "inputs.elapsed_hours", Some(0.0), None, false)?; + let mut hist = vec![]; + for h in &p.history { let mut seq = st.history_of(&h.of).to_vec(); seq.push(matches!(get(&vals, &h.of), Some(Json::Bool(true)))); + let n = if h.streak { seq.iter().rev().take_while(|&&b| b).count() } else { seq[seq.len().saturating_sub(h.window)..].iter().filter(|&&b| b).count() }; + hist.push((h.id.clone(), n.min(h.cap) as f64)); } + Ok((vals, hist, elapsed, ignored)) +} +fn cond_true(p: &Persona, k: &str, c: &Json, vals: &[(String, Json)], hist: &[(String, f64)]) -> bool { + let v = get(vals, k).cloned().unwrap_or_else(|| Json::Num(hist.iter().find(|(h, _)| h == k).map_or(0.0, |x| x.1))); + match p.input(k).map(|x| x.kind) { + Some(Kind::Flag) => v == *c, + Some(Kind::Level) => match c { Json::Str(s) => v.as_str() == Some(s), Json::Arr(a) => a.iter().any(|x| x.as_str().is_some() && x.as_str() == v.as_str()), _ => false }, + _ => { let x = v.as_f64().unwrap_or(0.0); + match c { Json::Num(t) => x >= *t, _ => x >= c.get("at_least").and_then(Json::as_f64).unwrap_or(f64::NEG_INFINITY) && x <= c.get("at_most").and_then(Json::as_f64).unwrap_or(f64::INFINITY) } } } +} +fn jstrs(v: &[String]) -> Json { Json::Arr(v.iter().map(|s| Json::Str(s.clone())).collect()) } + +/// persona + state + inputs -> (the turn's probbit-ir program, meta). A pure function; every weight rounded to 6 decimals. +fn compile(p: &Persona, st: &State, raw: &[(String, Json)], o: &Opts) -> R<(Json, Meta)> { + let (vals, hist, elapsed, ignored) = resolve_inputs(p, st, raw)?; + let (nv, ns) = (p.vars.len(), p.steps.len()); + let tix = |t: &str| -> usize { match t.strip_prefix("step.") { Some(s) => nv + p.steps.iter().position(|x| x == s).unwrap(), None => p.vars.iter().position(|v| v.id == t).unwrap() } }; + let mut contrib: Vec)>> = vec![vec![]; nv + ns]; + let mut mood_ev: Vec>> = p.vars.iter().map(|v| v.mood.then(|| vec![0.0; v.levels.len()])).collect(); + for (i, v) in p.vars.iter().enumerate() { contrib[i].push(("prior".into(), v.base.clone())); + if let Some(g) = st.shift(&v.id) { contrib[i].push(("genes".into(), centered(v.levels.len()).iter().map(|c| g * c).collect())); } } + for (j, s) in p.steps.iter().enumerate() { contrib[nv + j].push(("prior".into(), vec![0.0; ns])); + if let Some((_, pf)) = p.prefer.iter().find(|(t, _)| *t == format!("step.{s}")) { contrib[nv + j].push(("prefer".into(), pf.clone())); } } + let mut active_inputs = vec![]; + let mut push = |t: &str, src: String, w: Vec, contrib: &mut Vec)>>| { let i = tix(t); + match mood_ev.get_mut(i).and_then(Option::as_mut) { Some(ev) => for (a, b) in ev.iter_mut().zip(&w) { *a += b; }, None => contrib[i].push((src, w)) } }; + for x in &p.inputs { let v = get(&vals, &x.id).unwrap(); let g = st.react(&x.id).unwrap_or(1.0); + let (eff, scale): (&[(String, Vec)], f64) = match x.kind { + Kind::Flag => if *v == Json::Bool(true) { (&x.effects, 1.0) } else { (&[], 0.0) }, + Kind::Number => (&x.effects, v.as_f64().unwrap()), + Kind::Level => (x.by_level.iter().find(|(l, _)| Some(l.as_str()) == v.as_str()).map_or(&[][..], |(_, e)| &e[..]), 1.0) }; + if !eff.is_empty() && scale != 0.0 { active_inputs.push(x.id.clone()); } + for (t, vec) in eff { push(t, format!("input:{}", x.id), vec.iter().map(|y| g * scale * y).collect(), &mut contrib); } } + for h in &p.history { let n = hist.iter().find(|(k, _)| *k == h.id).unwrap().1.min(h.cap as f64); + if n != 0.0 && !h.effects.is_empty() { active_inputs.push(h.id.clone()); } + for (t, vec) in &h.effects { push(t, format!("history:{}", h.id), vec.iter().map(|y| n * y).collect(), &mut contrib); } } + let mut new_mood = vec![]; + for (i, v) in p.vars.iter().enumerate().filter(|(_, v)| v.mood) { + let k = if o.no_inertia { 0.0 } else { v.inertia }; + let decay = match v.half_life { Some(hl) if elapsed > 0.0 => 0.5f64.powf(elapsed / hl), _ => 1.0 }; + let prev = &st.mood.iter().find(|(id, _)| *id == v.id).unwrap().1; + let m: Vec = prev.iter().zip(mood_ev[i].as_ref().unwrap()).map(|(a, e)| r6(k * decay * a + (1.0 - k) * e)).collect(); + if m.iter().any(|&x| x != 0.0) { contrib[i].push(("mood_accumulator".into(), m.clone())); } + new_mood.push((v.id.clone(), m)); } + // habits in force and their rules + let active: Vec<&Habit> = p.habits.iter().filter(|h| !o.exclude.contains(&h.id) && h.when.iter().all(|(k, c)| cond_true(p, k, c, &vals, &hist))).collect(); + let (mut rules, mut owners) = (empty_rules(), vec![]); + let mut prev = st.prev.clone(); for v in &p.vars { prev.entry(v.id.clone()).or_insert_with(|| v.fallback.clone()); } + for h in &active { habit_rules(p, h, &prev, &mut rules, &mut owners); } + let owners = ordered_owners(owners); + // the program: values, vars (traits and moods, then the agenda steps), pairs, rules; positional = level i -> l + let pos_vals = p.eng.positional; let sem = values_of(p); + let lmax = p.vars.iter().map(|v| v.levels.len()).max().unwrap_or(0); + let values: Vec = if pos_vals { (0..lmax).map(|i| format!("l{i}")).chain(p.slots.iter().cloned()).collect() } else { sem.clone() }; + let name_of = |var: &str, val: &str| -> String { let id = var.strip_prefix("free.").unwrap_or(var); + match p.var(id) { Some(v) if pos_vals => format!("l{}", v.levels.iter().position(|l| l == val).unwrap_or(0)), _ => val.to_string() } }; + let field: Vec> = (0..nv + ns).map(|i| { let n = if i < nv { p.vars[i].levels.len() } else { ns }; + let mut tot = vec![0.0; n]; for (_, vec) in &contrib[i] { for (a, b) in tot.iter_mut().zip(vec) { *a += b; } } tot.into_iter().map(r6).collect() }).collect(); + let mut pv = vec![]; + for (i, v) in p.vars.iter().enumerate() { + let mut var = vec![("id".to_string(), Json::Str(v.id.clone())), ("allowed".into(), Json::Arr(v.levels.iter().map(|l| Json::Str(name_of(&v.id, l))).collect())), + ("h".into(), Json::Obj(v.levels.iter().zip(&field[i]).filter(|(_, w)| **w != 0.0).map(|(l, w)| (name_of(&v.id, l), Json::Num(*w))).collect()))]; + if let Some((_, c)) = o.clamps.iter().find(|(t, _)| *t == v.id) { var.push(("clamp".into(), Json::Str(name_of(&v.id, c)))); } + pv.push(Json::Obj(var)); } + for (j, s) in p.steps.iter().enumerate() { + pv.push(Json::Obj(vec![("id".into(), Json::Str(format!("step.{s}"))), ("allowed".into(), jstrs(&p.slots)), + ("h".into(), Json::Obj(p.slots.iter().zip(&field[nv + j]).filter(|(_, w)| **w != 0.0).map(|(l, w)| (l.clone(), Json::Num(*w))).collect()))])); } + let k = values.len(); let vpos = |v: &str| values.iter().position(|x| x == v).unwrap(); + let mut pairs = vec![]; + for (a, b, tab) in &p.couplings { + let (la, lb) = (&p.var(a).unwrap().levels, &p.var(b).unwrap().levels); let mut t = vec![vec![0.0; k]; k]; + for (x, l1) in la.iter().enumerate() { for (y, l2) in lb.iter().enumerate() { let w = r6(tab[x][y]); if w != 0.0 || !pos_vals { t[vpos(&name_of(a, l1))][vpos(&name_of(b, l2))] = w; } } } + pairs.push((a.clone(), b.clone(), t)); } + let pair_json = |i: &str, j: &str, t: &[Vec]| Json::Obj(vec![("i".into(), Json::Str(i.into())), ("j".into(), Json::Str(j.into())), + ("table".into(), Json::Arr(t.iter().map(|r| Json::Arr(r.iter().map(|x| Json::Num(*x)).collect())).collect()))]); + let comment = format!("probbit persona {} {} seed {} turn {}; active habits: {}", p.name, p.version, st.seed, st.turn, + if active.is_empty() { "none".to_string() } else { active.iter().map(|h| h.id.as_str()).collect::>().join(", ") }); + let use_twin = o.twin.unwrap_or(p.eng.twin); + let mut twin_vars = vec![]; + if use_twin { for v in &pv { let Json::Obj(f) = v else { continue }; twin_vars.push(Json::Obj(f.iter().filter(|(k, _)| k != "clamp") + .map(|(k, x)| if k == "id" { (k.clone(), Json::Str(format!("free.{}", x.as_str().unwrap()))) } else { (k.clone(), x.clone()) }).collect())); } } + pv.extend(twin_vars); + let mut prog = vec![("probbit_ir".to_string(), Json::Num(1.0)), ("comment".into(), Json::Str(comment)), ("values".into(), jstrs(&values)), ("vars".into(), Json::Arr(pv))]; + if !pairs.is_empty() { let mut pj: Vec = pairs.iter().map(|(a, b, t)| pair_json(a, b, t)).collect(); + if use_twin { pj.extend(pairs.iter().map(|(a, b, t)| pair_json(&format!("free.{a}"), &format!("free.{b}"), t))); } + prog.push(("pairs".into(), Json::Arr(pj))); } + for (ki, key) in RULE_KEYS.iter().enumerate() { + let mut lst: Vec = vec![]; + if *key == "all_different" && ns > 0 { lst.push(Json::Obj(vec![("vars".into(), Json::Arr(p.steps.iter().map(|s| Json::Str(format!("step.{s}"))).collect()))])); } + lst.extend(rules[ki].iter().map(|r| rename(r, key, &name_of))); + if *key == "all_different" && ns > 0 && use_twin { lst.push(Json::Obj(vec![("vars".into(), Json::Arr(p.steps.iter().map(|s| Json::Str(format!("free.step.{s}"))).collect()))])); } + if !lst.is_empty() { prog.push((key.to_string(), Json::Arr(lst))); } } + let conditional = active.iter().filter(|h| !h.when.is_empty()).map(|h| h.id.clone()).collect(); + let meta = Meta { vals, hist, ignored, active: active.iter().map(|h| h.id.clone()).collect(), conditional, active_inputs, rules, owners, new_mood, contrib, field, values: sem, twin: use_twin }; + Ok((Json::Obj(prog), meta)) +} +/// A habit rule with its level names renamed to the program's values (positional: l) +fn rename(r: &Json, key: &str, name_of: &dyn Fn(&str, &str) -> String) -> Json { + let Json::Obj(v) = r else { return r.clone() }; + let s = |x: &Json| x.as_str().unwrap_or("").to_string(); + let pair = |m: &Json| -> Json { match m.as_arr() { Some([a, b]) => Json::Arr(vec![a.clone(), Json::Str(name_of(&s(a), &s(b)))]), _ => m.clone() } }; + Json::Obj(v.iter().map(|(k, x)| { let y = match (key, k.as_str()) { + ("caps", "members") => Json::Arr(x.as_arr().unwrap_or(&[]).iter().map(pair).collect()), + ("implies", "if") => { let var = s(x.get("var").unwrap_or(&Json::Null)); Json::Obj(x.as_obj().unwrap_or(&[]).iter().map(|(a, b)| (a.clone(), if a == "value" { Json::Str(name_of(&var, &s(b))) } else { b.clone() })).collect()) } + ("implies", "then") => { let var = s(x.get("var").unwrap_or(&Json::Null)); Json::Obj(x.as_obj().unwrap_or(&[]).iter().map(|(a, b)| (a.clone(), if a == "in" { Json::Arr(b.as_arr().unwrap_or(&[]).iter().map(|y| Json::Str(name_of(&var, &s(y)))).collect()) } else { b.clone() })).collect()) } + ("tables", "forbid" | "allow") => { let vars: Vec = r.get("vars").and_then(Json::as_arr).map_or(vec![], |a| a.iter().map(s).collect()); + Json::Arr(x.as_arr().unwrap_or(&[]).iter().map(|t| Json::Arr(t.as_arr().unwrap_or(&[]).iter().zip(&vars).map(|(y, var)| Json::Str(name_of(var, &s(y)))).collect())).collect()) } + ("linear", "terms") => Json::Arr(x.as_arr().unwrap_or(&[]).iter().map(|t| match t.as_arr() { Some([a, b, w]) => Json::Arr(vec![a.clone(), Json::Str(name_of(&s(a), &s(b))), w.clone()]), _ => t.clone() }).collect()), + _ => x.clone() }; (k.clone(), y) }).collect()) +} + +// ---------------------------------------------------------------------------------------------------------- decode +struct Entry { level: String, p: f64, odds: Vec<(String, f64)>, released: bool, unheld: Option> } +/// A stance document under construction (docs/persona.md §4) +struct Out { status: String, verdict: Json, tier: Json, program: String, stance: Vec<(String, Entry)>, mood: Vec<(String, Entry)>, active: Vec, bound: Vec, + violations: usize, conflict: Vec, yielded: Vec, unsure: Vec, escalate: Option, inputs: Vec<(String, Json)>, ignored: Vec, + line: String, why: String, agenda: Option>, state_digest: String, held: Vec, timing: Option } +impl Out { + fn to_json(&self, p: &Persona, st: &State) -> Json { + let e = |m: &[(String, Entry)]| Json::Obj(m.iter().map(|(k, e)| { let odds = |o: &[(String, f64)]| Json::Obj(o.iter().map(|(l, x)| (l.clone(), Json::Num(*x))).collect()); + let mut f = vec![("level".to_string(), Json::Str(e.level.clone())), ("p".into(), Json::Num(e.p)), ("odds".into(), odds(&e.odds)), ("released".into(), Json::Bool(e.released))]; + if let Some(u) = &e.unheld { f.push(("odds_unheld".into(), odds(u))); } (k.clone(), Json::Obj(f)) }).collect()); + let s = |x: &str| Json::Str(x.to_string()); + let mut v = vec![("probbit_persona_turn".to_string(), Json::Num(1.0)), ("persona".into(), Json::Obj(vec![("name".into(), s(&p.name)), ("version".into(), s(&p.version)), ("digest".into(), s(&p.digest))])), + ("seed".into(), Json::Num(st.seed as f64)), ("turn".into(), Json::Num(st.turn as f64)), ("status".into(), s(&self.status)), + ("engine".into(), Json::Obj(vec![("verdict".into(), self.verdict.clone()), ("tier".into(), self.tier.clone()), ("program".into(), s(&self.program))])), + ("stance".into(), e(&self.stance)), ("mood".into(), e(&self.mood)), + ("habits".into(), Json::Obj(vec![("active".into(), jstrs(&self.active)), ("bound".into(), jstrs(&self.bound)), ("violations".into(), Json::Num(self.violations as f64)), + ("conflict".into(), jstrs(&self.conflict)), ("yielded".into(), jstrs(&self.yielded))])), + ("unsure".into(), jstrs(&self.unsure)), ("held".into(), jstrs(&self.held)), ("escalate".into(), self.escalate.as_deref().map_or(Json::Null, s)), + ("inputs".into(), Json::Obj(self.inputs.clone())), ("ignored".into(), jstrs(&self.ignored)), ("line".into(), s(&self.line)), + ("line_tokens".into(), Json::Num(est_tokens(&self.line) as f64)), ("why".into(), s(&self.why)), ("state_digest".into(), s(&self.state_digest))]; + if let Some(a) = &self.agenda { v.push(("agenda".into(), jstrs(a))); } + if let Some(t) = &self.timing { v.push(("timing".into(), t.clone())); } + Json::Obj(v) + } +} +/// Python's `max(range(n), key=...)`: the first index with the largest key +fn argmax(n: usize, key: impl Fn(usize) -> f64) -> usize { (0..n).fold(0, |b, i| if key(i) > key(b) { i } else { b }) } +/// The individual's resting stance per trait: the state's `rest` (a no-evidence turn's joint plan, computed at init), else the +/// argmax of prior + genes +fn baseline(p: &Persona, st: &State) -> HashMap { + if let Some(r) = st.rest.as_ref().filter(|r| !r.is_empty()) { return r.iter().cloned().collect(); } + p.vars.iter().map(|v| { let g = st.shift(&v.id).unwrap_or(0.0); let w: Vec = v.base.iter().zip(centered(v.levels.len())).map(|(b, c)| b + g * c).collect(); + (v.id.clone(), v.levels[argmax(w.len(), |i| round_to(w[i], 9))].clone()) }).collect() +} +/// The stance line: phrases by priority (0 bound habits, 1 triggered habits, 2 unsure notes, 3 traits off their resting level, +/// 4 traits at rest, 5 agenda order), cut from the lowest priority until it fits line.max_tokens (estimated) +#[allow(clippy::too_many_arguments)] +fn line(p: &Persona, stance: &HashMap, active: &[String], bound: &[String], unsure: &[String], order_steps: &[String], vouched: &HashMap, conditional: &[String], base: &HashMap) -> String { + let mut items: Vec<(u8, usize, String)> = vec![]; + for (i, h) in bound.iter().enumerate() { let s = &p.habit(h).say; if !s.is_empty() { items.push((0, i, s.clone())); } } + for (i, h) in active.iter().enumerate() { let s = &p.habit(h).say; if !bound.contains(h) && conditional.contains(h) && !s.is_empty() { items.push((1, i, s.clone())); } } + for (i, t) in unsure.iter().enumerate() { let v = p.var(t).unwrap(); items.push((2, i, format!("unsure about {t}: {}", v.hold.as_ref().map_or("ask".into(), |h| format!("keep it {h}"))))); } + for (pos, t) in p.order.iter().enumerate() { + let Some(l) = stance.get(t) else { continue }; if !vouched.get(t).copied().unwrap_or(false) || unsure.contains(t) { continue; } + let v = p.var(t).unwrap(); let s = &v.say[v.levels.iter().position(|x| x == l).unwrap()]; + if !s.is_empty() { items.push((if Some(l) != base.get(t) { 3 } else { 4 }, pos, s.clone())); } } + if !order_steps.is_empty() && p.say_order { items.push((5, 0, format!("order: {}", order_steps.join(" > ")))); } + let habit_text: Vec = items.iter().filter(|it| it.0 <= 1).map(|it| it.2.clone()).collect(); + items.retain(|it| it.0 < 3 || it.0 > 4 || !habit_text.iter().any(|h| h.contains(&it.2))); + items.sort_by_key(|it| (it.0, it.1)); + let mut keep: Vec = vec![]; for it in items { if !keep.contains(&it.2) { keep.push(it.2); } } + loop { let text = format!("{}{}", p.prefix, if keep.is_empty() { "neutral.".to_string() } else { format!("{}.", keep.join("; ")) }); + if est_tokens(&text) <= p.max_tokens || keep.is_empty() { return text; } keep.pop(); } +} +fn direction_word(p: &Persona, var: &str, vec: &[f64]) -> Option<(String, f64)> { + let v = p.var(var)?; let d = vec.iter().zip(centered(vec.len())).fold(0.0, |a, (x, c)| a + x * c); + (d.abs() >= 0.15).then(|| (format!("{var} {}", if d > 0.0 { v.levels.last().unwrap() } else { &v.levels[0] }), d.abs())) +} +fn why(p: &Persona, meta: &Meta, bound: &[String], status: &str) -> String { + let mut parts: Vec = p.habits.iter().filter(|h| bound.contains(&h.id)).map(|h| format!("{} (habit)", if h.say.is_empty() { h.id.replace('_', " ") } else { h.say.clone() })).collect(); + let mut srcs: Vec<(f64, String)> = vec![]; + let ids: Vec<&String> = p.inputs.iter().map(|x| &x.id).chain(p.history.iter().map(|h| &h.id)).collect(); + for id in ids { if !meta.active_inputs.contains(id) { continue; } + let (eff, scale, say): (&[(String, Vec)], f64, String) = if let Some(x) = p.input(id) { + let v = get(&meta.vals, id).unwrap(); + let eff = if x.kind == Kind::Level { x.by_level.iter().find(|(l, _)| Some(l.as_str()) == v.as_str()).map_or(&[][..], |(_, e)| &e[..]) } else { &x.effects[..] }; + let scale = if x.kind == Kind::Number { v.as_f64().unwrap() } else { 1.0 }; + let mut say = match &x.say { Json::Obj(m) => v.as_str().and_then(|l| get(m, l)).and_then(Json::as_str).unwrap_or(id).to_string(), s => s.as_str().unwrap_or(id).to_string() }; + if x.kind == Kind::Number { say = format!("{say} {:.1}", v.as_f64().unwrap()); } + (eff, scale, say) + } else { let h = p.history.iter().find(|h| h.id == *id).unwrap(); let n = meta.hist.iter().find(|(k, _)| k == id).unwrap().1; + (&h.effects[..], n.min(h.cap as f64), format!("{} {}", h.say, n as i64)) }; + let mut words: Vec<(String, f64)> = eff.iter().filter_map(|(t, vec)| direction_word(p, t, &vec.iter().map(|y| scale * y).collect::>())).collect(); + words.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + let tot = words.iter().fold(0.0, |a, w| a + w.1); + if !words.is_empty() { srcs.push((tot, format!("{say} -> {}", words.iter().take(2).map(|w| w.0.as_str()).collect::>().join(", ")))); } } + srcs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); + parts.extend(srcs.into_iter().take(2).map(|s| s.1)); + if status != "ok" { parts.push(format!("engine: {status}")); } + if parts.is_empty() { "baseline (no live evidence)".into() } else { parts.join("; ") } +} +/// The engine's value back to a level name (positional l -> the level) +fn back(p: &Persona, var: &str, val: &str) -> String { + let id = var.strip_prefix("free.").unwrap_or(var); + match (p.eng.positional, p.var(id), val.strip_prefix('l').and_then(|d| d.parse::().ok())) { (true, Some(v), Some(i)) if i < v.levels.len() && val[1..].bytes().all(|b| b.is_ascii_digit()) => v.levels[i].clone(), _ => val.to_string() } +} +/// An engine probability as the prototype read it: `probbit run`'s printed number (rounded to 6 decimals, json.rs `write`), then r6 +fn printed(x: f64) -> f64 { r6(if x.fract() == 0.0 { x } else { (x * 1e6).round() / 1e6 }) } + +/// The engine's answer -> (stance document, new state) +fn decode(p: &Persona, st: &State, program: &str, meta: &Meta, doc: &Json, conflict: Option<&[String]>, yielded: Option<&[String]>) -> (Out, State) { + let verdict = doc.get("verdict").cloned().unwrap_or(Json::Null); let vs = verdict.as_str().unwrap_or(""); + let plan: HashMap = doc.get("plan").and_then(Json::as_obj).map_or_else(HashMap::new, |o| o.iter().map(|(k, v)| (k.clone(), back(p, k, v.as_str().unwrap_or("")))).collect()); + let released: Vec<&str> = doc.get("released").and_then(Json::as_arr).map_or(vec![], |a| a.iter().filter_map(Json::as_str).collect()); + let mut status = match vs { "exact" | "diagnostics_passed" => "ok", "partial" => "partial", _ => "refused" }.to_string(); + let has_plan = !plan.is_empty() && p.vars.iter().all(|v| plan.contains_key(&v.id)); + let (mut stance, mut mood, mut vouched) = (vec![], vec![], HashMap::new()); + for v in &p.vars { + let lvl = if has_plan { plan[&v.id].clone() } else { v.fallback.clone() }; + let odds: Vec<(String, f64)> = if has_plan { let m = doc.get("marginals").and_then(|m| m.get(&v.id)).and_then(Json::as_obj); + v.levels.iter().map(|l| (l.clone(), m.and_then(|m| m.iter().find(|(k, _)| back(p, &v.id, k) == *l)).and_then(|(_, x)| x.as_f64()).map_or(0.0, printed))).collect() } + else { v.levels.iter().map(|l| (l.clone(), if *l == v.fallback { 1.0 } else { 0.0 })).collect() }; + let rel = has_plan && (vs == "exact" || released.contains(&v.id.as_str()) || vs == "diagnostics_passed"); vouched.insert(v.id.clone(), rel); + let e = Entry { p: odds.iter().find(|(l, _)| *l == lvl).map_or(0.0, |x| x.1), level: lvl, odds, released: rel, unheld: None }; + if v.mood { mood.push((v.id.clone(), e)); } else { stance.push((v.id.clone(), e)); } } + let (mut order_steps, mut steps_vouched) = (vec![], false); + if !p.steps.is_empty() { + if has_plan { order_steps = p.steps.clone(); order_steps.sort_by_key(|s| plan.get(&format!("step.{s}")).and_then(|x| p.slots.iter().position(|y| y == x)).unwrap_or(usize::MAX)); + steps_vouched = vs == "exact" || vs == "diagnostics_passed" || p.steps.iter().all(|s| released.contains(&format!("step.{s}").as_str())); } + else { order_steps = p.steps.clone(); } } + if !has_plan { status = "fallback".into(); } + let unsure: Vec = stance.iter().filter(|(t, e)| { let v = p.var(t).unwrap(); v.vouch > 0.0 && has_plan && e.odds.iter().fold(f64::NEG_INFINITY, |a, x| a.max(x.1)) < v.vouch }).map(|(t, _)| t.clone()).collect(); + let mut bound: Vec = vec![]; + if meta.twin && has_plan && p.vars.iter().all(|v| plan.contains_key(&format!("free.{}", v.id))) { + let free: HashMap = plan.iter().filter_map(|(k, x)| k.strip_prefix("free.").map(|k| (k.to_string(), x.clone()))).collect(); + for i in violations(&free, &meta.rules, &meta.values) { if !bound.contains(&meta.owners[i]) { bound.push(meta.owners[i].clone()); } } + bound.sort_by_key(|h| meta.active.iter().position(|a| a == h)); } + let mut flat: HashMap = stance.iter().chain(mood.iter()).map(|(k, e)| (k.clone(), e.level.clone())).collect(); + for (i, s) in order_steps.iter().enumerate() { flat.insert(format!("step.{s}"), p.slots[i].clone()); } + let viol = if has_plan { violations(&flat, &meta.rules, &meta.values).len() } else { 0 }; + let conflict = conflict.unwrap_or(&[]); let yielded = yielded.unwrap_or(&[]); + let escalate = if status == "fallback" && !conflict.is_empty() { Some(format!("habits conflict here (engine proved no stance obeys them all): {}", conflict.join(" + "))) } + else if status == "fallback" { let r = doc.get("reason").filter(|r| truthy(r)).map_or(String::new(), |r| format!(": {}", r.as_str().map_or_else(|| canon(r), str::to_string).chars().take(160).collect::())); + Some(format!("no stance: engine {}{r}", verdict.as_str().unwrap_or("None"))) } + else if !yielded.is_empty() { Some(format!("habit conflict resolved by priority: {} yielded", yielded.join(" + "))) } + else if status == "partial" || status == "refused" { let mut unv: Vec = p.vars.iter().filter(|v| !vouched[&v.id]).map(|v| v.id.clone()).collect(); + if !p.steps.is_empty() && !steps_vouched { unv.push("agenda".into()); } + Some(format!("engine {vs}: unvouched: {}", if unv.is_empty() { "none".to_string() } else { unv.join(", ") })) } + else { None }; + let line_active: Vec = meta.active.iter().filter(|h| !conflict.contains(h)).cloned().collect(); + let levels: HashMap = stance.iter().map(|(k, e)| (k.clone(), e.level.clone())).collect(); + let ln = line(p, &levels, &line_active, &bound, &unsure, if steps_vouched { &order_steps } else { &[] }, &vouched, &meta.conditional, &baseline(p, st)); + let w = why(p, meta, &bound, &status); + // the new state + let mut ns = st.clone(); ns.turn = st.turn + 1; ns.mood = meta.new_mood.clone(); + let mut seen: Vec<&str> = vec![]; + for h in &p.history { if seen.contains(&h.of.as_str()) { continue; } seen.push(&h.of); // one entry per flag and turn, however many features read it + let keep = p.history.iter().filter(|x| x.of == h.of).map(|x| x.window.max(x.cap)).max().unwrap_or(1).max(1); + let mut seq = st.history_of(&h.of).to_vec(); seq.push(matches!(get(&meta.vals, &h.of), Some(Json::Bool(true)))); + let seq = seq[seq.len().saturating_sub(keep)..].to_vec(); + match ns.history.iter_mut().find(|(k, _)| *k == h.of) { Some(x) => x.1 = seq, None => ns.history.push((h.of.clone(), seq)) } } + ns.prev = flat.iter().filter(|(k, _)| !k.starts_with("step.")).map(|(k, v)| (k.clone(), v.clone())).collect(); + ns.agenda = (!p.steps.is_empty()).then(|| order_steps.clone()); ns.seal(p); + let out = Out { status, verdict: verdict.clone(), tier: doc.get("tier").cloned().unwrap_or(Json::Null), program: program.to_string(), stance, mood, active: meta.active.clone(), bound, + violations: viol, conflict: conflict.to_vec(), yielded: yielded.to_vec(), unsure, escalate, inputs: meta.vals.iter().cloned().chain(meta.hist.iter().map(|(k, x)| (k.clone(), Json::Num(*x)))).collect(), + ignored: meta.ignored.clone(), line: ln, why: w, agenda: (!p.steps.is_empty()).then_some(order_steps), state_digest: ns.digest.clone(), held: vec![], timing: None }; + (out, ns) +} + +// ------------------------------------------------------------------------------------------------------------ turns +fn solve(p: &Persona, st: &State, prog: &Json, eng: Engine, calls: &mut usize) -> Json { *calls += 1; eng(prog, &flags(p, st)) } +fn ms(t: Instant) -> f64 { t.elapsed().as_secs_f64() * 1e3 } +/// A minimal set of active habits that cannot hold together (deletion filter: drop each habit in turn, keep it only if the rest +/// becomes feasible without it); [] when the rules without any habit are already infeasible +fn habit_conflict(p: &Persona, st: &State, raw: &[(String, Json)], active: &[String], o: &Opts, eng: Engine, calls: &mut usize) -> R> { + let mut feasible = |ex: &[String]| -> R { let mut oo = Opts { no_inertia: o.no_inertia, twin: Some(false), ..Default::default() }; oo.exclude = o.exclude.iter().chain(ex).cloned().collect(); + let (prog, _) = compile(p, st, raw, &oo)?; Ok(solve(p, st, &prog, eng, calls).get("verdict").and_then(Json::as_str) != Some("infeasible")) }; + if !feasible(active)? { return Ok(vec![]); } + let mut core: Vec = active.to_vec(); + for h in active { let trial: Vec = core.iter().filter(|x| *x != h).cloned().collect(); + let ex: Vec = active.iter().filter(|x| !trial.contains(x)).cloned().collect(); if !feasible(&ex)? { core = trial; } } + Ok(core) +} +/// One persona turn: (stance document, new state). A pure function of (persona, state, inputs, engine version). `timing` adds a +/// non-canonical `timing` object (compile / engine / decode ms, engine calls, the 1-minute load average). +pub fn turn(p: &Persona, st: &State, raw: &Json, no_inertia: bool, eng: Engine, timing: bool) -> R<(Json, State)> { + let raw: Vec<(String, Json)> = match raw { Json::Null => vec![], Json::Obj(v) => v.clone(), _ => return Err(perr("inputs", "must be a JSON object")) }; + for (n, (k, _)) in raw.iter().enumerate() { if raw[..n].iter().any(|(k2, _)| k2 == k) { return Err(perr(&format!("inputs.{k}"), "duplicate input")); } } + let t0 = Instant::now(); let mut calls = 0; + let o = Opts { no_inertia, ..Default::default() }; + let (mut prog, mut meta) = compile(p, st, &raw, &o)?; let mut ptext = digest_of(&text(&prog)); + let compile_ms = ms(t0); let t1 = Instant::now(); let mut doc = solve(p, st, &prog, eng, &mut calls); + let (mut conflict, mut yielded): (Option>, Option>) = (None, None); + if doc.get("verdict").and_then(Json::as_str) == Some("infeasible") && !meta.active.is_empty() { + let c = habit_conflict(p, st, &raw, &meta.active, &o, eng, &mut calls)?; conflict = Some(c.clone()); + if p.eng.yields && !c.is_empty() { let mut y: Vec = vec![]; let mut c = c; + loop { let mut rank = c.clone(); rank.sort_by(|a, b| { let (ha, hb) = (p.habit(a), p.habit(b)); ha.priority.partial_cmp(&hb.priority).unwrap().then(p.habit_index(b).cmp(&p.habit_index(a))) }); + y.push(rank[0].clone()); + let oo = Opts { no_inertia, exclude: y.clone(), ..Default::default() }; let (pr, me) = compile(p, st, &raw, &oo)?; prog = pr; meta = me; ptext = digest_of(&text(&prog)); + doc = solve(p, st, &prog, eng, &mut calls); + if doc.get("verdict").and_then(Json::as_str) != Some("infeasible") { conflict = None; break; } + c = habit_conflict(p, st, &raw, &meta.active, &oo, eng, &mut calls)?; conflict = Some(c.clone()); if c.is_empty() { break; } } + yielded = Some(y); } } + let mut engine_ms = ms(t1); + let (mut out, mut ns) = decode(p, st, &ptext, &meta, &doc, conflict.as_deref(), yielded.as_deref()); + // the persona's vouch rule: unsure traits with a hold level are held there (a second, clamped solve), consistent with every habit + let holds: Vec<(String, String)> = out.unsure.iter().filter_map(|t| { let v = p.var(t).unwrap(); let e = &out.stance.iter().find(|(k, _)| k == t).unwrap().1; + v.hold.as_ref().filter(|h| **h != e.level).map(|h| (t.clone(), h.clone())) }).collect(); + let mut held = vec![]; + if !holds.is_empty() { + let o2 = Opts { no_inertia, clamps: holds.clone(), ..Default::default() }; let (prog2, meta2) = compile(p, st, &raw, &o2)?; + let te = Instant::now(); let doc2 = solve(p, st, &prog2, eng, &mut calls); engine_ms += ms(te); + if matches!(doc2.get("verdict").and_then(Json::as_str), Some("exact" | "diagnostics_passed")) { + let (mut out2, ns2) = decode(p, st, &digest_of(&text(&prog2)), &meta2, &doc2, None, None); + out2.unsure = out.unsure.clone(); + for (t, _) in &holds { let u = out.stance.iter().find(|(k, _)| k == t).map(|(_, e)| e.odds.clone()); if let Some(e) = out2.stance.iter_mut().find(|(k, _)| k == t) { e.1.unheld = u; } } + out2.bound = out.bound.clone(); held = holds.iter().map(|(t, _)| t.clone()).collect(); held.sort(); + let levels: HashMap = out2.stance.iter().map(|(k, e)| (k.clone(), e.level.clone())).collect(); + let vouched: HashMap = out2.stance.iter().map(|(k, e)| (k.clone(), e.released)).collect(); + out2.line = line(p, &levels, &meta2.active, &out.bound, &out.unsure, out2.agenda.as_deref().unwrap_or(&[]), &vouched, &meta2.conditional, &baseline(p, st)); + out = out2; ns = ns2; } } + out.held = held; + if timing { let r3 = |x: f64| Json::Num((x * 1e3).round() / 1e3); + out.timing = Some(Json::Obj(vec![("compile_ms".into(), r3(compile_ms)), ("engine_ms".into(), r3(engine_ms)), ("decode_ms".into(), r3((ms(t0) - compile_ms - engine_ms).max(0.0))), ("turn_ms".into(), r3(ms(t0))), + ("engine_calls".into(), Json::Num(calls as f64)), ("holds".into(), Json::Num(out.held.len() as f64)), ("load_avg_1m".into(), crate::sys::loadavg().map_or(Json::Null, |l| Json::Num((l * 100.0).round() / 100.0)))])); } + Ok((out.to_json(p, st), ns)) +} + +/// A new individual: genes from the seed, an empty mood and history, the fallback levels as `prev`, and (with `rest`) the +/// resting stance: this individual's joint plan with no live evidence and a neutral mood (one engine call). +pub fn init(p: &Persona, seed: Option, rest: bool, eng: Engine) -> State { + let seed = seed.unwrap_or(p.seed); + let mut st = State { seed, turn: 0, persona: [p.name.clone(), p.version.clone(), p.digest.clone()], genes: genes(p, seed), + mood: p.vars.iter().filter(|v| v.mood).map(|v| (v.id.clone(), vec![0.0; v.levels.len()])).collect(), + history: { let mut h: Vec<(String, Vec)> = vec![]; for x in &p.history { if !h.iter().any(|(k, _)| *k == x.of) { h.push((x.of.clone(), vec![])); } } h }, + prev: p.vars.iter().map(|v| (v.id.clone(), v.fallback.clone())).collect(), agenda: (!p.steps.is_empty()).then(|| p.steps.clone()), rest: None, digest: String::new() }; + if rest { if let Ok((prog, _)) = compile(p, &st, &[], &Opts { twin: Some(false), ..Default::default() }) { + let doc = eng(&prog, &flags(p, &st)); + if let Some(plan) = doc.get("plan").and_then(Json::as_obj) { + st.rest = Some(p.vars.iter().filter_map(|v| plan.iter().find(|(k, _)| *k == v.id).map(|(_, x)| (v.id.clone(), back(p, &v.id, x.as_str().unwrap_or(""))))).collect()); } } } + st.seal(p); st +} +/// A script: a JSON list of per-turn input objects, or {"turns": [...]}; a turn may be {"inputs": {...}, ...} +pub fn script(j: &Json) -> R> { + let turns = match j { Json::Arr(a) => a, Json::Obj(_) => j.get("turns").and_then(Json::as_arr).ok_or_else(|| perr("script", "a JSON list of per-turn input objects (or {\"turns\": [...]})"))?, + _ => return Err(perr("script", "a JSON list of per-turn input objects (or {\"turns\": [...]})")) }; + turns.iter().enumerate().map(|(i, t)| { let t = if let Some(x) = t.get("inputs") { x } else { t }; + if matches!(t, Json::Obj(_) | Json::Null) { Ok(t.clone()) } else { Err(perr(&format!("script.turns[{i}]"), "must be an object of inputs")) } }).collect() +} +/// The tools `probbit_persona_init` / `probbit_persona_turn` (`probbit mcp`, probbit-wasm ops 4 / 5): stateless, the persona (an +/// inline document, or with `files` a path) and the state go in, the state comes back. init -> the state; turn -> {stance, state}. +pub fn tool(name: &str, args: &[(String, Json)], eng: Engine, files: bool) -> R { + let get = |k: &str| args.iter().find(|(x, _)| x == k).map(|(_, v)| v).filter(|v| !v.is_null()); + let p = match (get("persona"), get("persona_path")) { + (Some(d), None) => { if !matches!(d, Json::Obj(_)) { return Err(perr("arguments.persona", "must be an object (the JSON form of a persona file)")); } build(d)? } + (None, Some(_)) if !files => return Err(perr("arguments.persona_path", "no files here: give the persona document as persona")), + (None, Some(path)) => load(path.as_str().ok_or_else(|| perr("arguments.persona_path", "a file path"))?)?.0, + _ => return Err(perr("arguments", "give exactly one of persona (a document) or persona_path")) }; + let known: &[&str] = if name == "probbit_persona_init" { &["persona", "persona_path", "seed"] } else { &["persona", "persona_path", "state", "inputs", "flags"] }; + let mut extra: Vec<&str> = args.iter().map(|(k, _)| k.as_str()).filter(|k| !known.contains(k)).collect(); extra.sort_unstable(); + if let Some(k) = extra.first() { return Err(perr(&format!("arguments.{k}"), "unknown argument")); } + if name == "probbit_persona_init" { + let seed = match get("seed") { None => None, Some(s) => Some(s.as_f64().filter(|x| x.fract() == 0.0 && (0.0..=9_007_199_254_740_992.0).contains(x)).ok_or_else(|| perr("arguments.seed", "an integer from 0 to 2^53"))? as u64) }; + return Ok(init(&p, seed, true, eng).to_json(&p)); } + let st = get("state").filter(|s| matches!(s, Json::Obj(_))).ok_or_else(|| perr("arguments.state", "required: the state from probbit_persona_init or the previous turn"))?; + let st = State::read(&p, st)?; + let inputs = get("inputs").cloned().unwrap_or(Json::Obj(vec![])); if !matches!(inputs, Json::Obj(_)) { return Err(perr("arguments.inputs", "must be an object")); } + let fl = match get("flags") { None => vec![], Some(Json::Obj(v)) => v.clone(), Some(_) => return Err(perr("arguments.flags", "known flags: timing, no_inertia")) }; + if let Some((k, _)) = fl.iter().find(|(k, _)| k != "timing" && k != "no_inertia") { return Err(perr(&format!("arguments.flags.{k}"), "known flags: timing, no_inertia")); } + let on = |k: &str| -> R { match fl.iter().find(|(x, _)| x == k) { None => Ok(false), Some((_, Json::Bool(b))) => Ok(*b), Some(_) => Err(perr(&format!("arguments.flags.{k}"), "true or false")) } }; + let (stance, ns) = turn(&p, &st, &inputs, on("no_inertia")?, eng, on("timing")?)?; + Ok(Json::Obj(vec![("stance".into(), stance), ("state".into(), ns.to_json(&p))])) +} +/// `init` at `seed`, then every turn of the script -> (the stance documents, the final state) +pub fn replay(p: &Persona, seed: Option, turns: &[Json], no_inertia: bool, eng: Engine, timing: bool) -> R<(Vec, State)> { + let mut st = init(p, seed, true, eng); let mut out = vec![]; + for (i, t) in turns.iter().enumerate() { let (o, ns) = turn(p, &st, t, no_inertia, eng, timing).map_err(|e| perr(&format!("script.turns[{i}].{}", e.path), e.msg))?; out.push(o); st = ns; } + Ok((out, st)) +} +/// `compile`: the turn's program (insertion order; its sha256 is the stance's `engine.program`) +pub fn program(p: &Persona, st: &State, raw: &Json) -> R { + let raw: Vec<(String, Json)> = match raw { Json::Null => vec![], Json::Obj(v) => v.clone(), _ => return Err(perr("inputs", "must be a JSON object")) }; + Ok(text(&compile(p, st, &raw, &Opts::default())?.0)) +} + +/// Static check: for every conditional habit and every pair of them (with every unconditional one), and every previous level of +/// the traits their `prev` restrictions read: is there any stance that obeys them all? -> the contradictions with their resolution. +/// Conservative: a pair whose conditions cannot both hold for any input is still reported (the author decides). +pub fn lint(p: &Persona, eng: Engine) -> Vec { + let uncond: Vec<&Habit> = p.habits.iter().filter(|h| h.when.is_empty()).collect(); let cond: Vec<&Habit> = p.habits.iter().filter(|h| !h.when.is_empty()).collect(); + let mut combos: Vec> = cond.iter().map(|a| vec![*a]).collect(); + for i in 0..cond.len() { for j in i + 1..cond.len() { combos.push(vec![cond[i], cond[j]]); } } + let mut found: Vec<(Vec, Vec)> = vec![]; + for combo in combos { + let hs: Vec<&Habit> = uncond.iter().copied().chain(combo.iter().copied()).collect(); + let mut rel: Vec = hs.iter().flat_map(|h| h.then.iter().filter(|(_, r)| r.as_obj().is_some_and(|o| o.iter().any(|(_, x)| x.as_str().is_some_and(|s| s.starts_with("prev"))))).map(|(k, _)| k.clone())).collect(); + rel.sort(); rel.dedup(); + let mut q = p.clone(); q.habits = hs.iter().map(|h| Habit { when: vec![], ..(*h).clone() }).collect(); + let dims: Vec<&Vec> = rel.iter().map(|k| &p.var(k).unwrap().levels).collect(); let total: usize = dims.iter().map(|d| d.len()).product(); + for c in 0..total { // itertools.product order: the last trait varies fastest + let mut idx = vec![0; dims.len()]; let mut r = c; for d in (0..dims.len()).rev() { idx[d] = r % dims[d].len(); r /= dims[d].len(); } + let mut st = init(p, Some(p.seed), false, eng); for (k, &i) in rel.iter().zip(&idx) { st.prev.insert(k.clone(), p.var(k).unwrap().levels[i].clone()); } + let Ok((prog, _)) = compile(&q, &st, &[], &Opts { twin: Some(false), ..Default::default() }) else { continue }; + if eng(&prog, &flags(&q, &st)).get("verdict").and_then(Json::as_str) == Some("infeasible") { + let key: Vec = combo.iter().map(|h| h.id.clone()).collect(); + let when = Json::Obj(rel.iter().zip(&idx).map(|(k, &i)| (k.clone(), Json::Str(p.var(k).unwrap().levels[i].clone()))).collect()); + match found.iter_mut().find(|f| f.0 == key) { Some(f) => f.1.push(when), None => found.push((key, vec![when])) } } } + } + found.into_iter().map(|(hs, when)| { + let res = if p.eng.yields && hs.len() > 1 { let mut lo = hs.clone(); lo.sort_by(|a, b| p.habit(a).priority.partial_cmp(&p.habit(b).priority).unwrap().then(p.habit_index(b).cmp(&p.habit_index(a)))); + let tie = p.habit(&lo[0]).priority == p.habit(&lo[1]).priority; format!("yield: {} yields{}", lo[0], if tie { " (priority tie: the later-declared habit yields)" } else { "" }) } + else { "fallback: no stance on such turns (status fallback, escalate)".to_string() }; + Json::Obj(vec![("habits".into(), jstrs(&hs)), ("when_prev".into(), Json::Arr(when)), ("resolution".into(), Json::Str(res))]) }).collect() +} + +/// Total-variation distance between two odds objects over the same levels (None if their levels differ) +fn tv(oa: &[(String, Json)], ob: &[(String, Json)]) -> Option { + let mut ka: Vec<&String> = oa.iter().map(|(k, _)| k).collect(); let mut kb: Vec<&String> = ob.iter().map(|(k, _)| k).collect(); ka.sort(); kb.sort(); + (ka == kb).then(|| 0.5 * oa.iter().fold(0.0, |s, (k, p)| s + (p.as_f64().unwrap_or(0.0) - ob.iter().find(|(l, _)| l == k).and_then(|(_, q)| q.as_f64()).unwrap_or(0.0)).abs())) +} +/// Mean total-variation distance between two individuals' per-trait odds over turns and shared traits, and the fraction of +/// (turn, trait) where their levels differ (0 / 0 for identical individuals) +pub fn distance(a: &[Json], b: &[Json]) -> Json { + let (mut tvs, mut dis): (Vec, Vec) = (vec![], vec![]); let mut per: Vec<(String, Vec)> = vec![]; + for (x, y) in a.iter().zip(b) { let (Some(sa), Some(sb)) = (x.get("stance").and_then(Json::as_obj), y.get("stance")) else { continue }; + for (t, ea) in sa { let Some(eb) = sb.get(t) else { continue }; let (oa, ob) = (ea.get("odds").and_then(Json::as_obj).unwrap_or(&[]), eb.get("odds").and_then(Json::as_obj).unwrap_or(&[])); + let Some(d) = tv(oa, ob) else { continue }; + tvs.push(d); match per.iter_mut().find(|(k, _)| k == t) { Some(e) => e.1.push(d), None => per.push((t.clone(), vec![d])) } + dis.push(if ea.get("level") == eb.get("level") { 0.0 } else { 1.0 }); } } + let mean = |v: &[f64]| r6(v.iter().fold(0.0, |a, b| a + b) / v.len() as f64); + if tvs.is_empty() { return Json::Obj(vec![("tv".into(), Json::Null), ("level_disagreement".into(), Json::Null), ("per_trait".into(), Json::Obj(vec![]))]); } + per.sort_by(|x, y| x.0.cmp(&y.0)); + Json::Obj(vec![("tv".into(), Json::Num(mean(&tvs))), ("level_disagreement".into(), Json::Num(mean(&dis))), ("per_trait".into(), Json::Obj(per.iter().map(|(k, v)| (k.clone(), Json::Num(mean(v)))).collect()))]) +} +/// `diff`: two individuals (another seed, or another persona) over one script +pub fn diff(p: &Persona, sa: u64, q: &Persona, sb: u64, turns: &[Json], eng: Engine) -> R { + let (ta, _) = replay(p, Some(sa), turns, false, eng, false)?; let (tb, _) = replay(q, Some(sb), turns, false, eng, false)?; + // the raw total-variation sum over the shared traits (as `distance`, unrounded): the first turn with the largest one + let tvsum = |i: usize| ta[i].get("stance").and_then(Json::as_obj).map_or(0.0, |s| s.iter().filter_map(|(t, ea)| { + let eb = tb[i].get("stance")?.get(t)?; tv(ea.get("odds")?.as_obj()?, eb.get("odds")?.as_obj()?) }).fold(0.0, |a, b| a + b)); + let worst = argmax(ta.len().min(tb.len()), tvsum); + let who = |p: &Persona, s: u64| Json::Obj(vec![("persona".into(), Json::Str(p.name.clone())), ("seed".into(), Json::Num(s as f64)), ("genes".into(), genes(p, s))]); + let line_of = |t: &[Json]| t.get(worst).and_then(|x| x.get("line")).cloned().unwrap_or(Json::Null); + Ok(Json::Obj(vec![("a".into(), who(p, sa)), ("b".into(), who(q, sb)), ("turns".into(), Json::Num(turns.len() as f64)), ("distance".into(), distance(&ta, &tb)), + ("most_different_turn".into(), Json::Num(worst as f64)), ("line_a".into(), line_of(&ta)), ("line_b".into(), line_of(&tb))])) +} +/// `describe`: the persona's traits, moods, inputs, habits and agenda +pub fn describe(p: &Persona) -> Json { + let s = |x: &str| Json::Str(x.to_string()); + Json::Obj(vec![("name".into(), s(&p.name)), ("version".into(), s(&p.version)), ("digest".into(), s(&p.digest)), ("seed".into(), Json::Num(p.seed as f64)), + ("traits".into(), Json::Obj(p.vars.iter().filter(|v| !v.mood).map(|v| (v.id.clone(), Json::Obj(vec![("levels".into(), jstrs(&v.levels)), ("say".into(), jstrs(&v.say))]))).collect())), + ("moods".into(), Json::Obj(p.vars.iter().filter(|v| v.mood).map(|v| (v.id.clone(), jstrs(&v.levels))).collect())), + ("inputs".into(), Json::Obj(p.inputs.iter().map(|x| (x.id.clone(), Json::Obj(match x.kind { Kind::Level => vec![("kind".into(), s("level")), ("levels".into(), jstrs(&x.levels))], + Kind::Flag => vec![("kind".into(), s("flag"))], Kind::Number => vec![("kind".into(), s("number"))] }))).collect())), + ("habits".into(), Json::Arr(p.habits.iter().map(|h| s(&h.id)).collect())), ("agenda".into(), jstrs(&p.steps))]) +} +/// `check`: valid, with its digest and sizes (one engine call: the resting stance) +pub fn check(p: &Persona, eng: Engine) -> R { + let st = init(p, None, true, eng); let (prog, _) = compile(p, &st, &[], &Opts::default())?; + let n = |x: usize| Json::Num(x as f64); let len = |k: &str| prog.get(k).and_then(Json::as_arr).map_or(0, <[Json]>::len); + Ok(Json::Obj(vec![("ok".into(), Json::Bool(true)), ("name".into(), Json::Str(p.name.clone())), ("version".into(), Json::Str(p.version.clone())), ("digest".into(), Json::Str(p.digest.clone())), + ("traits".into(), n(p.vars.iter().filter(|v| !v.mood).count())), ("moods".into(), n(p.vars.iter().filter(|v| v.mood).count())), ("inputs".into(), n(p.inputs.len())), + ("history".into(), n(p.history.len())), ("habits".into(), n(p.habits.len())), ("steps".into(), n(p.steps.len())), ("program_vars".into(), n(len("vars"))), ("program_values".into(), n(len("values")))])) +} +/// `explain`: a turn in words: per trait and mood every contribution to its field, the joint odds, the habit-free twin's level +/// where it differs, the program's size and digest, the line and the why +pub fn explain(p: &Persona, st: &State, raw: &Json, eng: Engine) -> R { + let rawv: Vec<(String, Json)> = match raw { Json::Null => vec![], Json::Obj(v) => v.clone(), _ => return Err(perr("inputs", "must be a JSON object")) }; + let (prog, meta) = compile(p, st, &rawv, &Opts::default())?; let doc = eng(&prog, &flags(p, st)); let (out, _) = turn(p, st, raw, false, eng, false)?; + let plan: HashMap = doc.get("plan").and_then(Json::as_obj).map_or_else(HashMap::new, |o| o.iter().map(|(k, v)| (k.clone(), back(p, k, v.as_str().unwrap_or("")))).collect()); + let g = |k: &str| out.get(k).cloned().unwrap_or(Json::Null); let s = |j: &Json| j.as_str().map_or_else(|| canon(j), str::to_string); + let mut l = vec![format!("persona {} {} seed {} turn {} engine {} ({}) status {}", p.name, p.version, st.seed, st.turn, doc.get("verdict").map_or("None".into(), s), doc.get("tier").map_or("None".into(), s), s(&g("status")))]; + let inputs = g("inputs"); let hist = |k: &str| p.history.iter().any(|h| h.id == k); + l.push(format!("inputs: {}", inputs.as_obj().unwrap_or(&[]).iter().map(|(k, v)| format!("{k}={}", match v { Json::Bool(b) => if *b { "True".into() } else { "False".into() }, + Json::Num(x) if hist(k) => format!("{}", *x as i64), Json::Num(x) => py_repr(*x), x => s(x) })).collect::>().join(", "))); + let names = |j: &Json| j.as_arr().map_or(String::new(), |a| a.iter().filter_map(Json::as_str).collect::>().join(", ")); + let ign = names(&g("ignored")); if !ign.is_empty() { l.push(format!("ignored inputs: {ign}")); } + let hb = g("habits"); let (a, b) = (names(hb.get("active").unwrap_or(&Json::Null)), names(hb.get("bound").unwrap_or(&Json::Null))); + l.push(format!("habits active: {} | bound (changed the stance vs the habit-free twin): {}", if a.is_empty() { "-" } else { &a }, if b.is_empty() { "-" } else { &b })); + for (i, v) in p.vars.iter().enumerate() { + let e = g(if v.mood { "mood" } else { "stance" }).get(&v.id).cloned().unwrap_or(Json::Null); let lvl = e.get("level").map_or(String::new(), s); + let twin = if meta.twin { plan.get(&format!("free.{}", v.id)).filter(|t| **t != lvl).map_or(String::new(), |t| format!(" habit-free twin: {t}")) } else { String::new() }; + l.push(format!("{} {} -> {} (p {:.3}){twin}", if v.mood { "mood" } else { "trait" }, v.id, lvl, e.get("p").and_then(Json::as_f64).unwrap_or(0.0))); + let row = |vec: &[f64], fmt: &dyn Fn(f64) -> String| v.levels.iter().zip(vec).map(|(l, x)| format!("{l} {}", fmt(*x))).collect::>().join(" "); + for (src, vec) in &meta.contrib[i] { if vec.iter().any(|x| x.abs() > 5e-7) { l.push(format!(" {src:<24} {}", row(vec, &|x| format!("{x:+.2}")))); } } + l.push(format!(" {:<24} {}", "= field (log-weights)", row(&meta.field[i], &|x| format!("{x:+.2}")))); + let odds: Vec = v.levels.iter().map(|lv| e.get("odds").and_then(|o| o.get(lv)).and_then(Json::as_f64).unwrap_or(0.0)).collect(); + l.push(format!(" {:<24} {}", "odds (exact, joint)", row(&odds, &|x| format!("{x:.3}")))); } + for (a, b, _) in &p.couplings { l.push(format!("coupling {a} ~ {b}")); } + if !p.steps.is_empty() { l.push(format!("agenda: {}", names(&g("agenda")).replace(", ", " > "))); } + let cnt: Vec = ["pairs"].iter().chain(RULE_KEYS.iter()).filter_map(|k| prog.get(k).and_then(Json::as_arr).filter(|a| !a.is_empty()).map(|a| format!("{k} {}", a.len()))).collect(); + let pd = s(g("engine").get("program").unwrap_or(&Json::Null)); + l.push(format!("program: {} vars, {} values, {}; {}", prog.get("vars").and_then(Json::as_arr).map_or(0, <[Json]>::len), prog.get("values").and_then(Json::as_arr).map_or(0, <[Json]>::len), cnt.join(", "), &pd[..pd.len().min(23)])); + l.push(format!("line ({} tokens est.): {}", g("line_tokens").as_f64().unwrap_or(0.0) as i64, s(&g("line")))); l.push(format!("why: {}", s(&g("why")))); + Ok(l.join("\n")) +} diff --git a/probbit-cli/src/sys.rs b/probbit-cli/src/sys.rs index 53ce4dd..1001905 100644 --- a/probbit-cli/src/sys.rs +++ b/probbit-cli/src/sys.rs @@ -52,3 +52,13 @@ mod prio { #[cfg(unix)] pub use prio::{nice, set_low}; #[cfg(not(unix))] pub fn nice() -> Option { None } #[cfg(not(unix))] pub fn set_low() -> Option { None } + +/// The 1-minute load average (getloadavg(3), macOS and Linux); None elsewhere. `probbit persona turn --timing` reports it next +/// to every timing (a shared machine's timings are only comparable at a known load). +#[cfg(any(target_os = "macos", target_os = "linux"))] +pub fn loadavg() -> Option { + extern "C" { fn getloadavg(loadavg: *mut f64, nelem: i32) -> i32; } + let mut l = [0.0f64; 3]; (unsafe { getloadavg(l.as_mut_ptr(), 3) } >= 1).then_some(l[0]) +} +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +pub fn loadavg() -> Option { None } diff --git a/probbit-cli/src/yaml.rs b/probbit-cli/src/yaml.rs new file mode 100644 index 0000000..81f7bf1 --- /dev/null +++ b/probbit-cli/src/yaml.rs @@ -0,0 +1,241 @@ +//! The YAML subset a persona file may use (docs/persona.md §1), read into the crate's `Json`. Supported: block mappings +//! (`key: value`, `key:` + an indented block), block sequences (`- item`, including `- key: value` items), one-line flow +//! collections (`[a, b]`, `{a: 1, b: [x, y]}`), plain / single- / double-quoted scalars, numbers, true / false / null / ~ and +//! comments (`#` at the line start or after whitespace, outside quotes). Rejected with a line number, never guessed: tabs in +//! indentation, anchors / aliases / tags, block scalars (`|`, `>`), flow collections over several lines, duplicate keys, a second +//! document, and plain keys another YAML reader would not read as strings (yes, no, on, off, true, false, null, numbers: quote +//! them). Whitespace, line breaks and the quoting rules are Python's (the reference reader is the persona prototype's +//! `miniyaml.py`; this is a port of it). Differences, both on inputs it got wrong: a quoted scalar as a block-sequence item +//! (`- "x"`) is read (miniyaml refused it), and a number that is not exact in a double (an integer beyond 2^53, `1e999`) is an +//! error (miniyaml kept the integer exact, or read infinity). +use crate::json::Json; + +pub struct YamlError { pub line: usize, pub msg: String } +fn err(line: usize, msg: impl Into) -> Result { Err(YamlError { line, msg: msg.into() }) } + +/// Python's `str.isspace`: Unicode White_Space plus U+001C..U+001F (what `strip()`, `split()` and the regex `\s` use there) +pub fn space(c: char) -> bool { c.is_whitespace() || ('\u{1c}'..='\u{1f}').contains(&c) } +/// Python's `str.splitlines()` +fn lines(t: &str) -> Vec { + let (mut out, mut cur, mut it) = (vec![], String::new(), t.chars().peekable()); + while let Some(c) = it.next() { match c { + '\r' => { if it.peek() == Some(&'\n') { it.next(); } out.push(std::mem::take(&mut cur)); } + '\n' | '\u{b}' | '\u{c}' | '\u{1c}' | '\u{1d}' | '\u{1e}' | '\u{85}' | '\u{2028}' | '\u{2029}' => out.push(std::mem::take(&mut cur)), + c => cur.push(c) } } + if !cur.is_empty() { out.push(cur); } out +} + +/// The line without its comment (a `#` at the start or after a space / tab, outside quotes), right-stripped +fn strip_comment(s: &[char], ln: usize) -> Result { + let (mut out, mut q, mut i) = (String::new(), None::, 0); + while i < s.len() { let c = s[i]; + if let Some(qc) = q { out.push(c); + if qc == '"' && c == '\\' && i + 1 < s.len() { out.push(s[i + 1]); i += 2; continue; } + if c == qc { if qc == '\'' && i + 1 < s.len() && s[i + 1] == '\'' { out.push('\''); i += 2; continue; } q = None; } + } else { + if (c == '"' || c == '\'') && (i == 0 || " \t[{,:-".contains(s[i - 1])) { q = Some(c); } + else if c == '#' && (i == 0 || s[i - 1] == ' ' || s[i - 1] == '\t') { break; } + out.push(c); + } + i += 1; } + if q.is_some() { return err(ln, "unterminated quoted string"); } + Ok(out.trim_end_matches(space).to_string()) +} + +/// `^\s*-\s+X` (from the start) or `:\s+X` (anywhere) for the indicator X +fn indicator(s: &[char], x: char) -> bool { + let after_spaces = |mut j: usize| { let s0 = j; while j < s.len() && space(s[j]) { j += 1; } (j > s0 && j < s.len() && s[j] == x).then_some(()) }; + let mut j = 0; while j < s.len() && space(s[j]) { j += 1; } + if j < s.len() && s[j] == '-' && after_spaces(j + 1).is_some() { return true; } + (0..s.len()).any(|p| s[p] == ':' && after_spaces(p + 1).is_some()) +} +/// `(^|\s)[|>][-+0-9]*\s*$` +fn block_scalar(s: &[char]) -> bool { + (0..s.len()).any(|i| (s[i] == '|' || s[i] == '>') && (i == 0 || space(s[i - 1])) && { + let mut j = i + 1; while j < s.len() && (s[j] == '-' || s[j] == '+' || s[j].is_ascii_digit()) { j += 1; } s[j..].iter().all(|&c| space(c)) }) +} + +/// (indentation in spaces, the stripped line, line number) per content line +fn rows(text: &str) -> Result, YamlError> { + let mut rows = vec![]; + for (n, raw) in lines(text).into_iter().enumerate() { let ln = n + 1; + let raw = if ln == 1 { raw.strip_prefix('\u{feff}').map(str::to_string).unwrap_or(raw) } else { raw }; + let body = raw.trim_end_matches('\r'); + let lead = &body[..body.len() - body.trim_start_matches([' ', '\t']).len()]; + if lead.contains('\t') { return err(ln, "tab in indentation"); } + let s = strip_comment(&body.chars().collect::>(), ln)?; + let st = s.trim_matches(space); if st.is_empty() { continue; } + if st == "---" || st == "..." { if !rows.is_empty() { return err(ln, "only one document per file"); } continue; } + let cs: Vec = s.chars().collect(); + for (bad, what) in [('&', "anchor"), ('*', "alias"), ('!', "tag")] { + if s.trim_start_matches(space).starts_with(bad) || indicator(&cs, bad) { return err(ln, format!("{what} not supported")); } } + if block_scalar(&cs) { return err(ln, "block scalars (| or >) not supported"); } + rows.push((s.len() - s.trim_start_matches(' ').len(), st.to_string(), ln)); + } + Ok(rows) +} + +/// A JSON string literal as Python's `json.loads` reads one (strict: no raw control characters); the whole token +fn json_string(t: &[char]) -> Option { + let (mut out, mut i) = (String::new(), 1); + if t.first() != Some(&'"') { return None; } + let hex4 = |i: usize| -> Option { (i + 4 <= t.len()).then_some(())?; u32::from_str_radix(&t[i..i + 4].iter().collect::(), 16).ok().filter(|_| t[i..i + 4].iter().all(|c| c.is_ascii_hexdigit())) }; + loop { let c = *t.get(i)?; + match c { + '"' => { i += 1; break; } + '\\' => { let e = *t.get(i + 1)?; i += 2; + match e { '"' => out.push('"'), '\\' => out.push('\\'), '/' => out.push('/'), 'b' => out.push('\u{8}'), 'f' => out.push('\u{c}'), 'n' => out.push('\n'), 'r' => out.push('\r'), 't' => out.push('\t'), + 'u' => { let mut cp = hex4(i)?; i += 4; + if (0xD800..0xDC00).contains(&cp) && t.get(i) == Some(&'\\') && t.get(i + 1) == Some(&'u') { if let Some(lo) = hex4(i + 2).filter(|lo| (0xDC00..0xE000).contains(lo)) { cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00); i += 6; } } + out.push(char::from_u32(cp)?); } // a lone surrogate is no character + _ => return None } } + c if (c as u32) < 0x20 => return None, + c => { out.push(c); i += 1; } } } + t[i..].iter().all(|&c| matches!(c, ' ' | '\t' | '\n' | '\r')).then_some(out) +} +fn unquote(t: &[char], ln: usize) -> Result { + let tok: String = t.iter().collect(); + if t[0] == '"' { return json_string(t).map_or_else(|| err(ln, format!("bad double-quoted string {tok}")), Ok); } + if t.len() < 2 || t[t.len() - 1] != '\'' { return err(ln, format!("bad single-quoted string {tok}")); } + Ok(t[1..t.len() - 1].iter().collect::().replace("''", "'")) +} + +/// `^[-+]?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][-+]?[0-9]+)?$` -> Some(is an integer) +fn number_form(t: &str) -> Option { + let b = t.as_bytes(); let mut i = 0; let digits = |i: &mut usize| { let s = *i; while *i < b.len() && b[*i].is_ascii_digit() { *i += 1; } *i - s }; + if i < b.len() && (b[i] == b'-' || b[i] == b'+') { i += 1; } + if i < b.len() && b[i] == b'0' { i += 1; } else if i < b.len() && (b'1'..=b'9').contains(&b[i]) { digits(&mut i); } else { return None; } + let mut int = true; + if i < b.len() && b[i] == b'.' { i += 1; if digits(&mut i) == 0 { return None; } int = false; } + if i < b.len() && (b[i] == b'e' || b[i] == b'E') { + i += if b.get(i + 1).is_some_and(|c| *c == b'-' || *c == b'+') { 2 } else { 1 }; + if digits(&mut i) == 0 { return None; } + int = false; + } + (i == b.len()).then_some(int) +} +fn scalar(tok: &str, ln: usize) -> Result { + let t = tok.trim_matches(space); let Some(c0) = t.chars().next() else { return Ok(Json::Null) }; + if c0 == '"' || c0 == '\'' { return unquote(&t.chars().collect::>(), ln).map(Json::Str); } + if ["null", "Null", "NULL", "~"].contains(&t) { return Ok(Json::Null); } + if ["true", "True", "TRUE"].contains(&t) { return Ok(Json::Bool(true)); } + if ["false", "False", "FALSE"].contains(&t) { return Ok(Json::Bool(false)); } + if let Some(int) = number_form(t) { + let x: f64 = t.parse().map_err(|_| YamlError { line: ln, msg: format!("bad number {t}") })?; + if !x.is_finite() || (int && x.abs() > 9_007_199_254_740_992.0) { return err(ln, format!("number {t} is not exact in a double (at most 2^53 for an integer): quote it or write it smaller")); } + return Ok(Json::Num(x)); } + if c0 == '[' || c0 == '{' { return flow(&t.chars().collect::>(), ln); } + if "]},".contains(c0) || t.starts_with("? ") || c0 == '@' || c0 == '`' { return err(ln, format!("unexpected '{c0}'")); } + Ok(Json::Str(t.to_string())) +} + +/// One-line flow collection `[...]` / `{...}` +fn flow(t: &[char], ln: usize) -> Result { + struct F<'a> { t: &'a [char], pos: usize, ln: usize } + impl F<'_> { + fn ws(&mut self) { while self.pos < self.t.len() && self.t[self.pos] == ' ' { self.pos += 1; } } + /// (token, quoted) + fn token(&mut self, stops: &str) -> Result<(String, bool), YamlError> { + self.ws(); let (t, i) = (self.t, self.pos); + if i < t.len() && (t[i] == '"' || t[i] == '\'') { let q = t[i]; let mut j = i + 1; + while j < t.len() { if q == '"' && t[j] == '\\' { j += 2; continue; } + if t[j] == q { if q == '\'' && j + 1 < t.len() && t[j + 1] == '\'' { j += 2; continue; } break; } j += 1; } + if j >= t.len() { return err(self.ln, "unterminated quoted string in flow collection"); } + self.pos = j + 1; return Ok((unquote(&t[i..=j], self.ln)?, true)); } + let mut j = i; while j < t.len() && !stops.contains(t[j]) { j += 1; } + self.pos = j; Ok((t[i..j].iter().collect::().trim_matches(space).to_string(), false)) + } + fn value(&mut self) -> Result { + self.ws(); let ln = self.ln; if self.pos >= self.t.len() { return err(ln, "flow collection ends early"); } + match self.t[self.pos] { + '[' => { self.pos += 1; let mut out = vec![]; self.ws(); + if self.pos < self.t.len() && self.t[self.pos] == ']' { self.pos += 1; return Ok(Json::Arr(out)); } + loop { out.push(self.value()?); self.ws(); + match self.t.get(self.pos) { None => return err(ln, "unterminated ["), Some(',') => self.pos += 1, Some(']') => { self.pos += 1; return Ok(Json::Arr(out)); } + _ => return err(ln, "expected , or ] in flow sequence") } } } + '{' => { self.pos += 1; let mut out: Vec<(String, Json)> = vec![]; self.ws(); + if self.pos < self.t.len() && self.t[self.pos] == '}' { self.pos += 1; return Ok(Json::Obj(out)); } + loop { let (k, quoted) = self.token(":,}")?; + if !quoted { if k.is_empty() { return err(ln, "empty key in flow mapping"); } check_plain_key(&k, ln)?; } + self.ws(); if self.t.get(self.pos) != Some(&':') { return err(ln, format!("expected : after key {k:?}")); } self.pos += 1; + if out.iter().any(|(x, _)| *x == k) { return err(ln, format!("duplicate key {k:?}")); } + let v = self.value()?; out.push((k, v)); self.ws(); + match self.t.get(self.pos) { None => return err(ln, "unterminated {"), Some(',') => self.pos += 1, Some('}') => { self.pos += 1; return Ok(Json::Obj(out)); } + _ => return err(ln, "expected , or } in flow mapping") } } } + _ => { let (tok, quoted) = self.token(",]}")?; if quoted { Ok(Json::Str(tok)) } else { scalar(&tok, ln) } } } + } + } + let mut f = F { t, pos: 0, ln }; let v = f.value()?; f.ws(); + if f.pos != t.len() { return err(ln, format!("trailing text after flow collection: {:?}", t[f.pos..].iter().collect::())); } + Ok(v) +} + +const KEY_WORDS: [&str; 10] = ["true", "false", "null", "yes", "no", "on", "off", "y", "n", "~"]; +/// Every key is a string in this subset: a plain key another YAML reader would read as a bool / null / number is refused +fn check_plain_key(k: &str, ln: usize) -> Result<(), YamlError> { + if KEY_WORDS.contains(&k.to_lowercase().as_str()) || number_form(k).is_some() { return err(ln, format!("key {k:?} would not be a string in YAML: quote it")); } Ok(()) +} +/// `key: rest` -> Some((key, rest after the colon)); None = not a key. `item`: a block-sequence item, where a quoted scalar +/// without a colon after it is a scalar, not a malformed key. +fn split_key(s: &str, ln: usize, item: bool) -> Result, YamlError> { + let c: Vec = s.chars().collect(); + if c[0] == '"' || c[0] == '\'' { let q = c[0]; let mut j = 1; + while j < c.len() { if q == '"' && c[j] == '\\' { j += 2; continue; } + if c[j] == q { if q == '\'' && j + 1 < c.len() && c[j + 1] == '\'' { j += 2; continue; } break; } j += 1; } + let rest: String = c.get(j + 1..).map_or(String::new(), |r| r.iter().collect()); + if item && !rest.starts_with(':') { return Ok(None); } + let key = unquote(&c[..(j + 1).min(c.len())], ln)?; + if !rest.starts_with(':') { return err(ln, "expected : after quoted key"); } + return Ok(Some((key, rest[1..].to_string()))); } + // ^([^:\[\]{},#]+?):(\s|$) + let Some(p) = c.iter().position(|ch| ":[]{},#".contains(*ch)) else { return Ok(None) }; + if p == 0 || c[p] != ':' || c.get(p + 1).is_some_and(|&n| !space(n)) { return Ok(None); } + let key = c[..p].iter().collect::().trim_matches(space).to_string(); check_plain_key(&key, ln)?; + Ok(Some((key, c[p + 1..].iter().collect()))) +} +fn is_item(s: &str) -> bool { s == "-" || s.starts_with("- ") } + +/// The block whose lines start at rows[i] with exactly `indent` spaces -> (value, next index) +fn block(rows: &mut Vec<(usize, String, usize)>, mut i: usize, indent: usize) -> Result<(Json, usize), YamlError> { + if is_item(&rows[i].1) { + let mut out = vec![]; + while i < rows.len() && rows[i].0 == indent && is_item(&rows[i].1) { + let ln = rows[i].2; let rest = rows[i].1[1..].to_string(); let inner = rest.trim_start_matches(' ').to_string(); + if inner.is_empty() { + let v = if i + 1 < rows.len() && rows[i + 1].0 > indent { let (v, n) = block(rows, i + 1, rows[i + 1].0)?; i = n; v } else { i += 1; Json::Null }; + out.push(v); continue; } + let col = indent + 1 + (rest.len() - inner.len()); + let key = if inner.starts_with(['[', '{']) { None } else { split_key(&inner, ln, true)? }; + if key.is_some() { rows[i] = (col, inner, ln); let (v, n) = block(rows, i, col)?; i = n; out.push(v); } + else { out.push(scalar(&inner, ln)?); i += 1; } + } + if i < rows.len() && rows[i].0 > indent { return err(rows[i].2, "bad indentation"); } + return Ok((Json::Arr(out), i)); + } + let mut out: Vec<(String, Json)> = vec![]; + while i < rows.len() && rows[i].0 == indent { + let (ln, s) = (rows[i].2, rows[i].1.clone()); + if is_item(&s) { return err(ln, "sequence item where a mapping key was expected"); } + let Some((k, rest)) = split_key(&s, ln, false)? else { return err(ln, "expected `key: value`") }; + if out.iter().any(|(x, _)| *x == k) { return err(ln, format!("duplicate key {k:?}")); } + let rest = rest.trim_matches(space); + let v = if !rest.is_empty() { i += 1; scalar(rest, ln)? } + else if i + 1 < rows.len() && rows[i + 1].0 > indent { let (v, n) = block(rows, i + 1, rows[i + 1].0)?; i = n; v } + // a sequence at the key's own indentation (common YAML style) + else if i + 1 < rows.len() && rows[i + 1].0 == indent && is_item(&rows[i + 1].1) { let (v, n) = block(rows, i + 1, indent)?; i = n; v } + else { i += 1; Json::Null }; + out.push((k, v)); + } + if i < rows.len() && rows[i].0 > indent { return err(rows[i].2, "bad indentation"); } + Ok((Json::Obj(out), i)) +} + +/// Read a document of the subset. +pub fn load(text: &str) -> Result { + let mut rows = rows(text)?; + if rows.is_empty() { return Ok(Json::Null); } + if rows.len() == 1 && !rows[0].1.contains(':') && !rows[0].1.starts_with('-') { return scalar(&rows[0].1.clone(), rows[0].2); } + let base = rows[0].0; let (v, i) = block(&mut rows, 0, base)?; + if i != rows.len() { return err(rows[i].2, "unexpected text (indentation?)"); } + Ok(v) +} diff --git a/probbit-cli/tests/persona.rs b/probbit-cli/tests/persona.rs new file mode 100644 index 0000000..cb66050 --- /dev/null +++ b/probbit-cli/tests/persona.rs @@ -0,0 +1,401 @@ +//! `probbit persona` (docs/persona.md): the goldens byte for byte (they equal the reference implementation's, R31 parity), the YAML +//! subset's accepted and rejected inputs, 1,000-turn replays, habit properties over random personas, lint, the `evaluate` bridge, +//! refusals, malformed documents, the Python wrapper. +use std::io::Write; +use std::process::{Command, Stdio}; +#[allow(dead_code)] +#[path = "../src/json.rs"] +mod json; +#[allow(dead_code)] +#[path = "../src/yaml.rs"] +mod yaml; +use json::Json; + +fn probbit(args: &[&str], stdin: &str) -> (i32, String, String) { + let mut c = Command::new(env!("CARGO_BIN_EXE_probbit")).args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + let _ = c.stdin.take().unwrap().write_all(stdin.as_bytes()); + let o = c.wait_with_output().unwrap(); + (o.status.code().unwrap_or(-1), String::from_utf8_lossy(&o.stdout).into_owned(), String::from_utf8_lossy(&o.stderr).into_owned()) +} +fn ex(p: &str) -> String { format!("{}/../examples/persona/{p}", env!("CARGO_MANIFEST_DIR")) } +/// A file as text with "\n" line ends (a Windows checkout may have made them "\r\n") +fn read(p: &str) -> String { std::fs::read_to_string(p).unwrap_or_else(|e| panic!("{p}: {e}")).replace("\r\n", "\n") } +fn tmp(name: &str) -> String { format!("{}/persona-{}-{name}", env!("CARGO_TARGET_TMPDIR"), std::process::id()) } +fn parse(s: &str) -> Json { json::parse(s).unwrap_or_else(|e| panic!("not JSON ({}): {s:.300}", e.msg)) } +fn jw(j: &Json) -> String { json::write(j, false) } +fn s<'a>(j: &'a Json, path: &[&str]) -> &'a Json { path.iter().fold(j, |x, k| x.get(k).unwrap_or_else(|| panic!("no {k} in {:.200}", jw(x)))) } +const NAMES: [&str; 3] = ["ops-engineer", "tutor", "trader-assistant"]; +fn workday() -> Vec { s(&parse(&read(&ex("workday.json"))), &["turns"]).as_arr().unwrap().to_vec() } + +struct Mix(u64); +impl Mix { + fn next(&mut self) -> u64 { self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); let mut z = self.0; z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); z ^ (z >> 31) } + fn below(&mut self, k: usize) -> usize { (self.next() % k as u64) as usize } + fn unit(&mut self) -> f64 { (self.next() >> 11) as f64 / (1u64 << 53) as f64 } +} + +/// The acceptance test. For the three example personas, in both forms (YAML subset and JSON: one document, one digest), driven as +/// a host drives them: `init` = state0.json, `compile` of turn 0 = program-turn0.json, 20 `turn --state FILE` calls = workday.jsonl +/// line by line, the state file after them = final-state.json, `replay` = workday.jsonl, `lint` = lint.json. Every document is +/// canonical JSON. These files equal the R31 reference implementation's goldens (the R30 prototype with the canonical number +/// rule, docs/persona.md §5.2) byte for byte, except that each `engine.program` digest is of a program whose IR version key is +/// `probbit_ir` (the reference ran a 0.4.0 engine, whose key was `pbit_ir`; the program texts are otherwise byte-identical). +#[test] +fn stdout_matches_the_persona_goldens() { + let turns = workday(); assert_eq!(turns.len(), 20); + for n in NAMES { for form in ["yaml", "json"] { + let p = ex(&format!("{n}.{form}")); let g = |f: &str| read(&ex(&format!("golden/{n}/{f}"))); + let (c, s0, e) = probbit(&["persona", "init", &p], ""); assert_eq!(c, 0, "{e}"); assert_eq!(s0, g("state0.json"), "{n}.{form} state0"); + let st = tmp(&format!("{n}-{form}-state.json")); std::fs::write(&st, &s0).unwrap(); + let (c, prog, e) = probbit(&["persona", "compile", &p, "--state", &st, "--inputs", &jw(&turns[0])], ""); assert_eq!(c, 0, "{e}"); + assert_eq!(prog, g("program-turn0.json"), "{n}.{form} program of turn 0"); + let gold = g("workday.jsonl"); let lines: Vec<&str> = gold.lines().collect(); assert_eq!(lines.len(), 20); + for (i, t) in turns.iter().enumerate() { + let (c, out, e) = probbit(&["persona", "turn", &p, "--state", &st, "--inputs", &jw(t)], ""); assert_eq!(c, 0, "{e}"); + assert_eq!(out.trim_end(), lines[i], "{n}.{form} turn {i}"); } + assert_eq!(read(&st), g("final-state.json"), "{n}.{form} final state"); + let (c, rep, e) = probbit(&["persona", "replay", &p, "--script", &ex("workday.json")], ""); assert_eq!(c, 0, "{e}"); assert_eq!(rep, gold, "{n}.{form} replay"); + assert!(e.contains(&format!("final state {}", s(&parse(&g("final-state.json")), &["digest"]).as_str().unwrap())), "{e}"); + let (_, lint, _) = probbit(&["persona", "lint", &p], ""); assert_eq!(jw(s(&parse(&lint), &["conflicts"])), jw(s(&parse(&g("lint.json")), &["conflicts"])), "{n} lint"); + let _ = std::fs::remove_file(&st); + } } + // the three individuals are different people: same script, different persona files + let first: Vec = NAMES.iter().map(|n| read(&ex(&format!("golden/{n}/workday.jsonl"))).lines().next().unwrap().to_string()).collect(); + assert!(first[0] != first[1] && first[1] != first[2]); +} + +/// `probbit persona --help` documents every subcommand and the exit codes; bad flags and subcommands exit 2. +#[test] +fn persona_help_and_flags() { + let (c, h, e) = probbit(&["persona", "--help"], ""); assert_eq!(c, 0, "{e}"); assert!(h.starts_with("usage: probbit persona"), "{h}"); + for w in ["init", "turn", "replay", "explain", "diff", "lint", "check", "compile", "describe", "--no-inertia", "--timing", "exit: 0"] { assert!(h.contains(w), "help lacks {w}"); } + let p = ex("tutor.yaml"); + for args in [vec!["persona"], vec!["persona", "nope", &p], vec!["persona", "init"], vec!["persona", "init", &p, "--bogus"], vec!["persona", "turn", &p], vec!["persona", "init", &p, "--seed", "x"], vec!["persona", "init", &p, "--seed", "-1"]] { + let (c, out, e) = probbit(&args, ""); assert_eq!(c, 2, "{args:?}: {out} {e}"); assert!(out.is_empty(), "{args:?}: a flag error goes to stderr"); } + let (c, d, _) = probbit(&["persona", "describe", &p], ""); assert_eq!(c, 0); + assert_eq!(s(&parse(&d), &["agenda"]).as_arr().unwrap().len(), 4); assert!(s(&parse(&d), &["traits", "emoji", "levels"]).as_arr().is_some()); + let (c, k, _) = probbit(&["persona", "check", &p], ""); assert_eq!(c, 0); let k = parse(&k); + assert_eq!((s(&k, &["traits"]).as_f64(), s(&k, &["moods"]).as_f64(), s(&k, &["habits"]).as_f64()), (Some(9.0), Some(2.0), Some(6.0))); + let (c, x, e) = probbit(&["persona", "explain", &p, "--script", &ex("workday.json"), "--turn", "4"], ""); assert_eq!(c, 0, "{e}"); + assert!(x.contains("turn 4") && x.contains("odds (exact, joint)") && x.contains("line (") && x.contains("why: "), "{x}"); + let (c, d, e) = probbit(&["persona", "diff", &p, "--seed", "1", "--seed2", "2", "--script", &ex("workday.json")], ""); assert_eq!(c, 0, "{e}"); + let tv = s(&parse(&d), &["distance", "tv"]).as_f64().unwrap(); assert!(tv > 0.0 && tv < 1.0, "{d}"); + let (_, same, _) = probbit(&["persona", "diff", &p, "--seed", "3", "--seed2", "3", "--script", &ex("workday.json")], ""); + assert_eq!(s(&parse(&same), &["distance", "tv"]).as_f64(), Some(0.0)); // the same individual: distance 0 +} + +/// The YAML subset reads what the reference reader (miniyaml.py) reads, to the same values, and refuses what it refuses, at the same +/// line. Two deliberate differences, both on inputs the reference got wrong, are pinned at the end. +#[test] +fn yaml_subset_reads_and_refuses_like_the_reference() { + let ok: [(&str, &str); 15] = [ + ("a: 1\nb: [x, y]\n", r#"{"a":1,"b":["x","y"]}"#), + ("- a\n- b: 2\n c: 3\n- [1, 2]\n", r#"["a",{"b":2,"c":3},[1,2]]"#), + ("key:\n - x\n - y\n", r#"{"key":["x","y"]}"#), + ("key:\n- x\n- y\nnext: 1\n", r#"{"key":["x","y"],"next":1}"#), + ("a: 'it''s'\nb: \"q\\\"x\\u00e9\"\n", r#"{"a":"it's","b":"q\"xé"}"#), + ("a: ~\nb: null\nc: true\nd: FALSE\ne: -0.5e2\nf: +3\n", r#"{"a":null,"b":null,"c":true,"d":false,"e":-50,"f":3}"#), + ("# comment\na: x # trailing\nb: 'a # not a comment'\nc: x#y\n", r##"{"a":"x","b":"a # not a comment","c":"x#y"}"##), + ("---\na: {x: 1, w: [a, b], 'z': c, 'y': d}\n", r#"{"a":{"x":1,"w":["a","b"],"z":"c","y":"d"}}"#), + ("\u{feff}a: 1\r\nb: 2\r\n", r#"{"a":1,"b":2}"#), + ("a: [x, ]\n", r#"{"a":["x",null]}"#), + ("a:\nb: 2\n", r#"{"a":null,"b":2}"#), + ("say: [plain and businesslike, \"\", \"blunt: state it\"]\n", r#"{"say":["plain and businesslike","","blunt: state it"]}"#), + ("a: 01x\nb: yes\n", r#"{"a":"01x","b":"yes"}"#), + ("seq:\n -\n a: 1\n - b\n", r#"{"seq":[{"a":1},"b"]}"#), + ("[1, 2.5, x]\n", r#"[1,2.5,"x"]"#), + ]; + for (src, want) in ok { match yaml::load(src) { Ok(v) => assert_eq!(jw(&v), jw(&parse(want)), "{src:?}"), Err(e) => panic!("{src:?} refused at line {}: {}", e.line, e.msg) } } + let bad: [(&str, usize, &str); 21] = [ + ("a:\n\tb: 1\n", 2, "tab in indentation"), ("a: &x 1\n", 1, "anchor"), ("a: *x\n", 1, "alias"), ("a: !!str 1\n", 1, "tag"), ("a: |\n text\n", 1, "block scalars"), + ("a: [1,\n 2]\n", 1, "ends early"), ("a: 1\na: 2\n", 2, "duplicate key"), ("yes: 1\n", 1, "would not be a string"), ("1: x\n", 1, "would not be a string"), + ("a: 1\n---\nb: 2\n", 2, "only one document"), ("a: 'x\n", 1, "unterminated"), ("a:\n b: 1\n c: 2\n", 3, "bad indentation"), ("? a\n: b\n", 1, "expected `key: value`"), + ("- a\nb: 1\n", 2, "unexpected text"), ("a: \"x\ty\"\n", 1, "bad double-quoted string"), ("a: [x, y\n", 1, "unterminated ["), ("a: {b: 1, b: 2}\n", 1, "duplicate key"), + ("a: {on: 1}\n", 1, "would not be a string"), ("a: ]\n", 1, "unexpected ']'"), ("a: `x`\n", 1, "unexpected '`'"), ("- &a x\n", 1, "anchor"), + ]; + for (src, line, msg) in bad { match yaml::load(src) { Ok(v) => panic!("{src:?} read as {}", jw(&v)), Err(e) => { assert_eq!(e.line, line, "{src:?}: {}", e.msg); assert!(e.msg.contains(msg), "{src:?}: {}", e.msg); } } } + // differences from the reference reader: a quoted scalar is a sequence item (it refused it), and no number that is not exact + // in a double is read (it read 1e999 as infinity and kept big integers exact, so digests would differ between implementations) + assert_eq!(jw(&yaml::load("- \"x\"\n- 'y'\n").ok().unwrap()), r#"["x","y"]"#); + for src in ["a: 1e999\n", "a: 12345678901234567890\n"] { assert!(yaml::load(src).is_err(), "{src:?}"); } + // every example persona reads to the same document as its JSON form + for n in NAMES { assert_eq!(jw(&yaml::load(&read(&ex(&format!("{n}.yaml")))).ok().unwrap()), jw(&parse(&read(&ex(&format!("{n}.json"))))), "{n}"); } +} + +/// The standard inputs at random (fixed seed): sentiment, error, loss, praise, criticism, claim_done, stakes, time_pressure, and +/// the example personas' own (confused, task); every persona ignores what it does not declare. +fn random_script(r: &mut Mix, n: usize) -> Vec { + (0..n).map(|_| { let mut t: Vec<(String, Json)> = vec![]; + let u = r.unit(); if u < 0.2 { t.push(("sentiment".into(), Json::Str("negative".into()))); } else if u > 0.8 { t.push(("sentiment".into(), Json::Str("positive".into()))); } + for (k, p) in [("error", 0.15), ("loss", 0.03), ("praise", 0.1), ("criticism", 0.07), ("claim_done", 0.1), ("confused", 0.1)] { if r.unit() < p { t.push((k.into(), Json::Bool(true))); } } + let x = (r.unit().powi(2) * 100.0).round() / 100.0; if x >= 0.05 { t.push(("stakes".into(), Json::Num(x))); } + let x = (r.unit().powi(3) * 100.0).round() / 100.0; if x >= 0.05 { t.push(("time_pressure".into(), Json::Num(x))); } + if r.unit() < 0.3 { t.push(("task".into(), Json::Str(["chat", "question", "code", "ops", "explain"][r.below(5)].into()))); } + if r.unit() < 0.03 { t.push(("elapsed_hours".into(), Json::Num((r.unit() * 1000.0).round() / 100.0))); } + Json::Obj(t) }).collect() +} + +/// A 1,000-turn random script replays byte for byte: twice in two processes, and through `turn --state FILE` one process per turn +/// (the first 60 turns), for every example persona and two seeds. +#[test] +fn a_1000_turn_replay_is_byte_identical() { + let mut r = Mix(20_261_002); let script = Json::Arr(random_script(&mut r, 1000)); let sp = tmp("long1000.json"); std::fs::write(&sp, jw(&script)).unwrap(); + let turns = script.as_arr().unwrap(); + for n in NAMES { for seed in ["1", "7"] { + let p = ex(&format!("{n}.yaml")); + let (c, a, e) = probbit(&["persona", "replay", &p, "--seed", seed, "--script", &sp], ""); assert_eq!(c, 0, "{e}"); + let (_, b, e2) = probbit(&["persona", "replay", &p, "--seed", seed, "--script", &sp], ""); + assert_eq!(a.lines().count(), 1000); assert!(a == b && e == e2, "{n} seed {seed}: two replays differ"); + let st = tmp(&format!("{n}-{seed}-long.json")); let (_, s0, _) = probbit(&["persona", "init", &p, "--seed", seed], ""); std::fs::write(&st, s0).unwrap(); + for (i, (t, line)) in turns.iter().zip(a.lines()).take(60).enumerate() { + let (c, out, e) = probbit(&["persona", "turn", &p, "--state", &st, "--inputs", &jw(t)], ""); assert_eq!(c, 0, "{e}"); assert_eq!(out.trim_end(), line, "{n} seed {seed} turn {i}"); } + let _ = std::fs::remove_file(&st); + } } + let _ = std::fs::remove_file(&sp); +} + +/// A restriction of a `then` habit -> the allowed levels (docs/persona.md §2.5), independently of the implementation +fn allowed(levels: &[String], r: &Json, prev: &str) -> Vec { + let idx = |l: &str| -> usize { if let Some(i) = levels.iter().position(|x| x == l) { return i; } + let p = levels.iter().position(|x| x == prev).unwrap() as i64; (p + match l { "prev-1" => -1, "prev+1" => 1, _ => 0 }).clamp(0, levels.len() as i64 - 1) as usize }; + match r { Json::Arr(a) => levels.iter().filter(|l| a.iter().any(|x| x.as_str() == Some(l.as_str()))).cloned().collect(), + _ => { let (k, x) = &r.as_obj().unwrap()[0]; + match k.as_str() { "not" => levels.iter().filter(|l| !x.as_arr().unwrap().iter().any(|y| y.as_str() == Some(l.as_str()))).cloned().collect(), + "at_most" => levels[..=idx(x.as_str().unwrap())].to_vec(), _ => levels[idx(x.as_str().unwrap())..].to_vec() } } } +} +/// A random persona (JSON): 3-5 traits, maybe a mood, couplings, a flag, a level and a number input, maybe a streak, 2-4 conditional +/// habits restricting traits (level lists, `not`, `at_most` / `at_least` with levels and prev, prev-1, prev+1), conflicts resolved by +/// fallback or yield. Every trait's fallback is its first level. +/// (trait id, its levels) and (habit id, its `then` restrictions) +type Levels = Vec<(String, Vec)>; +type Thens = Vec<(String, Vec<(String, Json)>)>; +fn random_persona(r: &mut Mix, k: usize) -> (Json, Levels, Thens) { + let names = ["low", "mid", "high", "max"]; let num = |x: f64| Json::Num((x * 100.0).round() / 100.0); let st = |x: &str| Json::Str(x.into()); + let nt = 3 + r.below(3); let mut traits = vec![]; let mut levels = vec![]; + for t in 0..nt { let n = 2 + r.below(3); let lv: Vec = names[..n].iter().map(|s| s.to_string()).collect(); + traits.push(Json::Obj(vec![("id".into(), st(&format!("t{t}"))), ("levels".into(), Json::Arr(lv.iter().map(|l| st(l)).collect())), + ("prior".into(), Json::Arr((0..n).map(|_| Json::Num((1 + r.below(9)) as f64)).collect())), ("spread".into(), num(r.below(6) as f64 * 0.1)), + ("say".into(), Json::Arr(lv.iter().map(|l| st(&format!("t{t} {l}"))).collect())), ("fallback".into(), st(&lv[0]))])); + levels.push((format!("t{t}"), lv)); } + let mood = r.below(2) == 0; let mut ids: Vec = levels.iter().map(|x| x.0.clone()).collect(); if mood { ids.push("m0".into()); } + let target = |r: &mut Mix| ids[r.below(ids.len())].clone(); + let eff = |r: &mut Mix| Json::Obj((0..1 + r.below(3)).map(|_| (target(r), num(r.unit() * 3.0 - 1.5))).fold(vec![], |mut v: Vec<(String, Json)>, (k, x)| { if !v.iter().any(|y| y.0 == k) { v.push((k, x)); } v })); + let mut couplings = vec![]; for _ in 0..1 + r.below(3) { let (a, b) = (target(r), target(r)); if a != b { couplings.push(Json::Obj(vec![("vars".into(), Json::Arr(vec![st(&a), st(&b)])), ("align".into(), num(r.unit() * 2.0 - 1.0))])); } } + let inputs = vec![Json::Obj(vec![("id".into(), st("f0")), ("kind".into(), st("flag")), ("effects".into(), eff(r))]), + Json::Obj(vec![("id".into(), st("lv")), ("kind".into(), st("level")), ("levels".into(), Json::Arr(vec![st("neg"), st("neu"), st("pos")])), ("default".into(), st("neu")), ("effects".into(), Json::Obj(vec![("neg".into(), eff(r)), ("pos".into(), eff(r))]))]), + Json::Obj(vec![("id".into(), st("n0")), ("kind".into(), st("number")), ("reactivity_spread".into(), num(0.3)), ("effects".into(), eff(r))])]; + let streak = r.below(2) == 0; + let whens = [("f0", Json::Bool(true)), ("lv", st("neg")), ("n0", num(0.5)), ("streak", Json::Num(2.0))]; + let mut habits = vec![]; let mut hdefs = vec![]; + for h in 0..2 + r.below(3) { let (wk, wv) = whens[r.below(if streak { 4 } else { 3 })].clone(); + let mut then: Vec<(String, Json)> = vec![]; + for _ in 0..1 + r.below(2) { let (t, lv) = levels[r.below(nt)].clone(); if then.iter().any(|x| x.0 == t) { continue; } + let restr = match r.below(5) { 0 => Json::Arr(vec![st(&lv[r.below(lv.len())])]), 1 => Json::Obj(vec![("not".into(), Json::Arr(vec![st(&lv[r.below(lv.len())])]))]), + 2 => Json::Obj(vec![("at_most".into(), st(["prev", "prev-1", &lv[r.below(lv.len())]][r.below(3)]))]), + 3 => Json::Obj(vec![("at_least".into(), st(["prev", "prev+1", &lv[r.below(lv.len())]][r.below(3)]))]), + _ => Json::Arr(vec![st(&lv[0]), st(&lv[lv.len() - 1])]) }; + then.push((t, restr)); } + hdefs.push((format!("h{h}"), then.clone())); + habits.push(Json::Obj(vec![("id".into(), st(&format!("h{h}"))), ("when".into(), Json::Obj(vec![(wk.into(), wv)])), ("then".into(), Json::Obj(then)), + ("say".into(), st(&format!("habit {h}"))), ("priority".into(), Json::Num(r.below(3) as f64))])); } + let mut doc = vec![("probbit_persona".to_string(), Json::Num(1.0)), ("identity".into(), Json::Obj(vec![("name".into(), st(&format!("Random {k}"))), ("version".into(), st("1")), ("seed".into(), Json::Num(1.0))])), + ("traits".into(), Json::Arr(traits))]; + if mood { doc.push(("moods".into(), Json::Arr(vec![Json::Obj(vec![("id".into(), st("m0")), ("levels".into(), Json::Arr(vec![st("down"), st("even"), st("up")])), ("inertia".into(), num(0.5))])]))); } + doc.push(("couplings".into(), Json::Arr(couplings))); doc.push(("inputs".into(), Json::Arr(inputs))); + if streak { doc.push(("history".into(), Json::Arr(vec![Json::Obj(vec![("id".into(), st("streak")), ("of".into(), st("f0")), ("kind".into(), st("streak")), ("cap".into(), Json::Num(3.0)), ("effects".into(), eff(r))])]))); } + doc.push(("habits".into(), Json::Arr(habits))); + doc.push(("engine".into(), Json::Obj(vec![("on_conflict".into(), st(if r.below(2) == 0 { "yield" } else { "fallback" }))]))); + (Json::Obj(doc), levels, hdefs) +} +fn with(doc: &Json, k: &str, v: Json) -> Json { let Json::Obj(o) = doc else { panic!() }; let mut o = o.clone(); o.retain(|(x, _)| x != k); o.push((k.into(), v)); Json::Obj(o) } + +/// Habit properties over random personas and random inputs (12 personas x 2 seeds x 90 turns = 2,160 turns): whenever there is a +/// stance, every habit in force holds (`violations` 0, and re-checked here from the persona's own rules and the previous turn's +/// levels); every habit reported `bound` is broken by the habit-free twin (the same persona without habits: its unaries do not +/// depend on earlier stances, so its plan is the twin's); `values: positional` and `values: semantic` give the same stances. +#[test] +fn habits_hold_bound_habits_bind_and_value_alphabets_agree() { + let (mut turns, mut bound_seen, mut checked) = (0, 0, 0); let mut r = Mix(0xBEEF); + for k in 0..12 { + let (doc, levels, hdefs) = random_persona(&mut r, k); + let lvl = |t: &str| &levels.iter().find(|x| x.0 == t).unwrap().1; + let sem = with(&doc, "engine", Json::Obj(vec![("on_conflict".into(), s(&doc, &["engine", "on_conflict"]).clone()), ("values".into(), Json::Str("semantic".into()))])); + let free = with(&with(&doc, "habits", Json::Arr(vec![])), "engine", Json::Obj(vec![("twin".into(), Json::Bool(false))])); + let script = Json::Arr((0..90).map(|_| { let mut t = vec![]; if r.unit() < 0.35 { t.push(("f0".to_string(), Json::Bool(true))); } + if r.unit() < 0.5 { t.push(("lv".into(), Json::Str(["neg", "neu", "pos"][r.below(3)].into()))); } + if r.unit() < 0.6 { t.push(("n0".into(), Json::Num((r.unit() * 100.0).round() / 100.0))); } + Json::Obj(t) }).collect()); + let (pp, ps, pf, sp) = (tmp(&format!("rand{k}.json")), tmp(&format!("rand{k}-sem.json")), tmp(&format!("rand{k}-free.json")), tmp(&format!("rand{k}-script.json"))); + for (f, d) in [(&pp, &doc), (&ps, &sem), (&pf, &free), (&sp, &script)] { std::fs::write(f, jw(d)).unwrap(); } + for seed in ["1", "2"] { + let run = |p: &str| -> Vec { let (c, out, e) = probbit(&["persona", "replay", p, "--seed", seed, "--script", &sp], ""); assert_eq!(c, 0, "{e} {}", jw(&doc)); out.lines().map(parse).collect() }; + let (main, semantic, twin) = (run(&pp), run(&ps), run(&pf)); + let mut prev: Vec<(String, String)> = levels.iter().map(|(t, lv)| (t.clone(), lv[0].clone())).collect(); + for (i, ((a, b), f)) in main.iter().zip(&semantic).zip(&twin).enumerate() { + turns += 1; let status = s(a, &["status"]).as_str().unwrap(); + // positional == semantic (the persona digest, the program and the state digest name the variant) + let strip = |x: &Json| { let Json::Obj(o) = x else { panic!() }; Json::Obj(o.iter().filter(|(k, _)| !["persona", "engine", "state_digest"].contains(&k.as_str())).cloned().collect()) }; + assert_eq!(jw(&strip(a)), jw(&strip(b)), "persona {k} seed {seed} turn {i}: positional != semantic"); + let level = |x: &Json, t: &str| s(x, &["stance", t, "level"]).as_str().unwrap().to_string(); + let pv = |t: &str| prev.iter().find(|x| x.0 == t).unwrap().1.clone(); + if status != "fallback" { + assert_eq!(s(a, &["habits", "violations"]).as_f64(), Some(0.0), "persona {k} turn {i}"); + for h in s(a, &["habits", "active"]).as_arr().unwrap() { let then = &hdefs.iter().find(|x| x.0 == h.as_str().unwrap()).unwrap().1; + for (t, restr) in then { checked += 1; assert!(allowed(lvl(t), restr, &pv(t)).contains(&level(a, t)), "persona {k} turn {i}: habit {} broken on {t}", h.as_str().unwrap()); } } + for h in s(a, &["habits", "bound"]).as_arr().unwrap() { bound_seen += 1; let then = &hdefs.iter().find(|x| x.0 == h.as_str().unwrap()).unwrap().1; + assert_eq!(s(f, &["status"]).as_str(), Some("ok")); + assert!(then.iter().any(|(t, restr)| !allowed(lvl(t), restr, &pv(t)).contains(&level(f, t))), "persona {k} turn {i}: bound habit {} holds on the twin", h.as_str().unwrap()); } + } + prev = levels.iter().map(|(t, _)| (t.clone(), level(a, t))).collect(); + } + } + for f in [&pp, &ps, &pf, &sp] { let _ = std::fs::remove_file(f); } + } + eprintln!("habit properties: {turns} turns, {checked} restrictions re-checked, {bound_seen} bound habits broken by the twin"); + assert!(turns >= 2000 && bound_seen > 50 && checked > 1000, "{turns} {bound_seen} {checked}"); +} + +/// `lint` passes clean personas and finds planted contradictions: statically (`lint`), at load (an unconditional habit the fallback +/// stance breaks) and at run time (`fallback` with the conflicting habits named, or the lower-priority habit yielding). +#[test] +fn lint_finds_planted_contradictions_and_passes_clean_personas() { + for (n, conflicts) in [("ops-engineer", 0), ("trader-assistant", 0), ("tutor", 1)] { + let (c, out, e) = probbit(&["persona", "lint", &ex(&format!("{n}.yaml"))], ""); assert_eq!(c, 0, "{e}"); let l = parse(&out); + assert_eq!(s(&l, &["conflicts"]).as_arr().unwrap().len(), conflicts, "{n}"); assert_eq!(s(&l, &["ok"]), &Json::Bool(true)); } + let base = parse(&read(&ex("ops-engineer.json"))); + let habits = |extra: &str| { let mut h = s(&base, &["habits"]).as_arr().unwrap().to_vec(); h.push(parse(extra)); with(&base, "habits", Json::Arr(h)) }; + let planted = habits(r#"{"id": "planted_long", "when": {"stakes": 0.5}, "then": {"verbosity": ["full"]}, "say": "at stakes, full detail"}"#); + let pf = tmp("planted.json"); std::fs::write(&pf, jw(&planted)).unwrap(); + let (c, out, _) = probbit(&["persona", "lint", &pf], ""); assert_eq!(c, 1, "{out}"); let l = parse(&out); + let f = &s(&l, &["conflicts"]).as_arr().unwrap()[0]; + assert_eq!(jw(s(f, &["habits"])), r#"["after_error_shorter","planted_long"]"#); assert_eq!(s(f, &["when_prev"]).as_arr().unwrap().len(), 9); // every previous caution x verbosity + assert!(s(f, &["resolution"]).as_str().unwrap().starts_with("fallback")); + // at run time: both in force -> no stance, the conflict named, the line without them + let st = tmp("planted-state.json"); let (_, s0, _) = probbit(&["persona", "init", &pf], ""); std::fs::write(&st, s0).unwrap(); + let (c, out, e) = probbit(&["persona", "turn", &pf, "--state", &st, "--inputs", r#"{"error": true, "stakes": 0.8}"#], ""); assert_eq!(c, 0, "{e}"); let t = parse(&out); + assert_eq!(s(&t, &["status"]).as_str(), Some("fallback")); assert_eq!(jw(s(&t, &["habits", "conflict"])), r#"["after_error_shorter","planted_long"]"#); + assert!(s(&t, &["escalate"]).as_str().unwrap().contains("habits conflict here")); let line = s(&t, &["line"]).as_str().unwrap(); + assert!(!line.contains("at stakes, full detail") && !line.contains("shorter than last time"), "{line}"); + // on_conflict yield: resolved by priority (a tie: the later-declared habit yields), and the turn has a stance again + let yielding = with(&planted, "engine", parse(r#"{"on_conflict": "yield"}"#)); std::fs::write(&pf, jw(&yielding)).unwrap(); + let (c, out, _) = probbit(&["persona", "lint", &pf], ""); assert_eq!(c, 0, "{out}"); + assert_eq!(s(&s(&parse(&out), &["conflicts"]).as_arr().unwrap()[0], &["resolution"]).as_str(), Some("yield: planted_long yields (priority tie: the later-declared habit yields)")); + let (_, s0, _) = probbit(&["persona", "init", &pf], ""); std::fs::write(&st, s0).unwrap(); + let (_, out, _) = probbit(&["persona", "turn", &pf, "--state", &st, "--inputs", r#"{"error": true, "stakes": 0.8}"#], ""); let t = parse(&out); + assert_eq!(s(&t, &["status"]).as_str(), Some("ok")); assert_eq!(jw(s(&t, &["habits", "yielded"])), r#"["planted_long"]"#); assert_eq!(s(&t, &["habits", "violations"]).as_f64(), Some(0.0)); + // an unconditional habit the fallback stance breaks: refused when the persona is read + std::fs::write(&pf, jw(&habits(r#"{"id": "always_full", "then": {"verbosity": ["full"]}}"#))).unwrap(); + let (c, out, _) = probbit(&["persona", "check", &pf], ""); assert_eq!(c, 2); + assert_eq!(jw(s(&parse(&out), &["error"])), r#"{"code":"persona","path":"traits","message":"the fallback stance breaks unconditional habit(s): always_full"}"#); + for f in [&pf, &st] { let _ = std::fs::remove_file(f); } +} + +/// Refusals map to the stance: forced onto the sampler at 8 sweeps (`engine.op: sample`), the gate vouches for nothing; every turn +/// is then `refused` (or `fallback` when no chain started) with `escalate` naming what is unvouched, and the line carries only the +/// habits in force (always safe to state): no unvouched trait's phrase. At the default (exact) the same turns are `ok`. +#[test] +fn refusals_leave_unvouched_traits_out_of_the_line() { + let doc = parse(&read(&ex("tutor.json"))); let says: Vec = s(&doc, &["traits"]).as_arr().unwrap().iter().flat_map(|t| s(t, &["say"]).as_arr().unwrap().iter().filter_map(|x| x.as_str().map(str::to_string)).filter(|x| !x.is_empty()).collect::>()).collect(); + let habit_says: Vec = s(&doc, &["habits"]).as_arr().unwrap().iter().filter_map(|h| h.get("say").and_then(Json::as_str).map(str::to_string)).collect(); + let f = tmp("sample8.json"); std::fs::write(&f, jw(&with(&doc, "engine", parse(r#"{"op": "sample", "sweeps": 8, "on_conflict": "yield"}"#)))).unwrap(); + let (c, out, e) = probbit(&["persona", "replay", &f, "--script", &ex("workday.json")], ""); assert_eq!(c, 0, "{e}"); + let mut refused = 0; + for (i, l) in out.lines().enumerate() { let t = parse(l); let st = s(&t, &["status"]).as_str().unwrap(); assert!(["refused", "partial", "fallback"].contains(&st), "turn {i}: {st}"); + let line = s(&t, &["line"]).as_str().unwrap(); let esc = s(&t, &["escalate"]).as_str().unwrap(); + if st == "refused" { refused += 1; assert!(esc.starts_with("engine refused: unvouched: "), "{esc}"); + let items: Vec<&str> = line.trim_start_matches("Stance: ").trim_end_matches('.').split("; ").collect(); + assert!(line == "Stance: neutral." || items.iter().all(|x| habit_says.iter().any(|h| h == x)), "turn {i}: {line}"); + assert!(!says.iter().any(|x| items.contains(&x.as_str())), "turn {i}: a trait phrase in a refused line: {line}"); } + for (k, e) in s(&t, &["stance"]).as_obj().unwrap() { if e.get("released") == Some(&Json::Bool(false)) && st == "partial" { + let v = s(&doc, &["traits"]).as_arr().unwrap().iter().find(|x| s(x, &["id"]).as_str() == Some(k)).unwrap(); + let lv = s(e, &["level"]).as_str().unwrap(); let i = s(v, &["levels"]).as_arr().unwrap().iter().position(|x| x.as_str() == Some(lv)).unwrap(); + let phrase = s(v, &["say"]).as_arr().unwrap()[i].as_str().unwrap(); assert!(phrase.is_empty() || !line.contains(phrase), "unvouched {k} in {line}"); } } } + assert!(refused >= 10, "{refused}"); + let (_, exact, _) = probbit(&["persona", "replay", &ex("tutor.yaml"), "--script", &ex("workday.json")], ""); + assert!(exact.lines().all(|l| s(&parse(l), &["status"]).as_str() == Some("ok"))); + let _ = std::fs::remove_file(&f); +} + +/// The `evaluate` bridge (docs/persona.md §8): a turn's program as a System One request, one `choice` question per variable, the +/// persona's own field as `probbit.logw` (judge weight 0) and its pairs and habits as `probbit.rules`. `probbit evaluate` then gives +/// the stance the `run` path gave, level and odds (within 2e-6), on every workday turn of the three example personas. +#[test] +fn the_evaluate_bridge_at_judge_weight_0_equals_the_run_path() { + let mut same = 0; + for n in NAMES { + let p = ex(&format!("{n}.yaml")); let st = tmp(&format!("{n}-bridge.json")); let (_, s0, _) = probbit(&["persona", "init", &p], ""); std::fs::write(&st, s0).unwrap(); + let levels: Vec<(String, Vec)> = { let d = parse(&probbit(&["persona", "describe", &p], "").1); + s(&d, &["traits"]).as_obj().unwrap().iter().map(|(k, v)| (k.clone(), s(v, &["levels"]).as_arr().unwrap().iter().map(|x| x.as_str().unwrap().to_string()).collect())).collect() }; + for (i, t) in workday().iter().enumerate() { + let (_, prog, _) = probbit(&["persona", "compile", &p, "--state", &st, "--inputs", &jw(t)], ""); let prog = parse(&prog); + let (c, out, e) = probbit(&["persona", "turn", &p, "--state", &st, "--inputs", &jw(t)], ""); assert_eq!(c, 0, "{e}"); let stance = parse(&out); + let main = |j: &Json| !jw(j).contains("\"free."); + let vars: Vec<&Json> = s(&prog, &["vars"]).as_arr().unwrap().iter().filter(|v| main(v)).collect(); + let questions: Vec<(String, Json)> = vars.iter().map(|v| (s(v, &["id"]).as_str().unwrap().to_string(), Json::Obj(vec![("type".into(), Json::Str("choice".into())), + ("criteria".into(), Json::Obj(s(v, &["allowed"]).as_arr().unwrap().iter().map(|a| (a.as_str().unwrap().to_string(), a.clone())).collect()))]))).collect(); + let logw: Vec<(String, Json)> = vars.iter().map(|v| (s(v, &["id"]).as_str().unwrap().to_string(), s(v, &["h"]).clone())).collect(); + let rules: Vec<(String, Json)> = ["pairs", "tables", "precedes", "implies", "linear", "caps", "all_different"].iter().filter_map(|k| prog.get(k).and_then(Json::as_arr) + .map(|l| (k.to_string(), Json::Arr(l.iter().filter(|r| main(r)).cloned().collect())))).filter(|(_, l)| !l.as_arr().unwrap().is_empty()).collect(); + let req = Json::Obj(vec![("model".into(), Json::Str("judge+persona".into())), ("questions".into(), Json::Obj(questions)), + ("probbit".into(), Json::Obj(vec![("logw".into(), Json::Obj(logw)), ("rules".into(), Json::Obj(rules))]))]); + let (c, out, e) = probbit(&["evaluate"], &jw(&req)); assert_eq!(c, 0, "{n} turn {i}: {e} {out:.300}"); let ev = parse(&out); + for (tr, lv) in &levels { + let a = s(&ev, &["answers", tr]); let idx = |v: &str| v[1..].parse::().unwrap(); + assert_eq!(lv[idx(s(a, &["choice"]).as_str().unwrap())], s(&stance, &["stance", tr, "level"]).as_str().unwrap(), "{n} turn {i} {tr}"); + for (o, x) in s(a, &["probabilities"]).as_obj().unwrap() { let want = s(&stance, &["stance", tr, "odds", &lv[idx(o)]]).as_f64().unwrap(); + assert!((x.as_f64().unwrap() - want).abs() < 2e-6, "{n} turn {i} {tr} {o}: {} vs {want}", jw(x)); } + same += 1; } + } + let _ = std::fs::remove_file(&st); + } + assert_eq!(same, 20 * (8 + 9 + 8)); +} + +/// Malformed persona documents (byte edits, inserted tabs / anchors / quotes / brackets, truncation, swapped or duplicated lines of +/// the example files, in both forms), states and inputs: never a crash; exit 0 (still a valid persona) or exit 2 with exactly one +/// `{"error": {"code": "persona", "path", "message"}}` object on stdout. +#[test] +fn malformed_documents_are_one_error_object_never_a_crash() { + let mut r = Mix(0xF022); let (mut ok, mut err) = (0, 0); let f = tmp("fuzz-persona"); + for case in 0..600 { + let n = NAMES[r.below(3)]; let json_form = r.below(3) == 0; let mut b = read(&ex(&format!("{n}.{}", if json_form { "json" } else { "yaml" }))).into_bytes(); + for _ in 0..1 + r.below(3) { let p = r.below(b.len()); + match r.below(7) { + 0 => { b.remove(p); } + 1 => { const INS: [&str; 16] = ["\t", "&a ", "*a", "!t ", "| ", "[", "{", ":", "- ", "#", "\"", "'", " ", "\n", "1e999", "yes: 1\n"]; let x = INS[r.below(INS.len())]; b.splice(p..p, x.bytes()); } + 2 => b.truncate(p), + 3 => { let q = (p + 1 + r.below(40)).min(b.len()); let s = b[p..q].to_vec(); b.splice(q..q, s); } + 4 => { let mut lines: Vec = String::from_utf8_lossy(&b).lines().map(str::to_string).collect(); let (i, j) = (r.below(lines.len()), r.below(lines.len())); lines.swap(i, j); b = lines.join("\n").into_bytes(); } + 5 => { const W: [&str; 6] = ["-1", "99", "true", "null", "\"x\"", "[]"]; let s = String::from_utf8_lossy(&b).to_string(); let digits: Vec = s.char_indices().filter(|c| c.1.is_ascii_digit()).map(|c| c.0).collect(); + if let Some(&d) = digits.get(r.below(digits.len().max(1))) { let mut t = s.clone(); t.replace_range(d..d + 1, W[r.below(W.len())]); b = t.into_bytes(); } } + _ => { let s = String::from_utf8_lossy(&b).to_string(); b = s.replacen(["levels", "prior", "when", "then", "kind", "effects"][r.below(6)], ["level", "priors", "If", "than", "type", "effect"][r.below(6)], 1).into_bytes(); } + } } + let path = format!("{f}-{case}.{}", if json_form { "json" } else { "yaml" }); std::fs::write(&path, &b).unwrap(); + let (c, out, e) = probbit(&["persona", "check", &path], ""); + match c { 0 => { ok += 1; assert_eq!(s(&parse(&out), &["ok"]), &Json::Bool(true)); } + 2 => { err += 1; assert_eq!(out.lines().count(), 1, "case {case}: {out}"); let d = parse(&out); let Json::Obj(o) = &d else { panic!() }; assert_eq!(o.len(), 1, "case {case}"); + assert_eq!(s(&d, &["error", "code"]).as_str(), Some("persona"), "case {case}: {out}"); assert!(s(&d, &["error", "path"]).as_str().is_some() && s(&d, &["error", "message"]).as_str().is_some()); } + _ => panic!("case {case}: exit {c}\n{e}\n{}", String::from_utf8_lossy(&b)) } + let _ = std::fs::remove_file(&path); + } + eprintln!("malformed personas: {ok} still valid, {err} one error object each"); + assert!(ok > 30 && err > 300, "{ok} {err}"); + // states and inputs + let p = ex("tutor.yaml"); let st = tmp("fuzz-state.json"); let (_, s0, _) = probbit(&["persona", "init", &p], ""); let s0j = parse(&s0); + for (state, inputs, path) in [(s0.clone(), r#"{"stakes": "high"}"#, "inputs.stakes"), (s0.clone(), r#"{"sentiment": "angry"}"#, "inputs.sentiment"), (s0.clone(), "[1]", "inputs"), + (s0.clone(), "{\"loss\": 1}", "inputs.loss"), (s0.clone(), "{not json", "inputs"), (s0.replace("\"turn\":0", "\"turn\":5"), "{}", "state.digest"), + ("[]".into(), "{}", "state"), ("{\"probbit_persona_state\": 2}".into(), "{}", "state"), (jw(&with(&s0j, "seed", Json::Num(9.0))), "{}", "state.digest")] { + std::fs::write(&st, &state).unwrap(); let (c, out, e) = probbit(&["persona", "turn", &p, "--state", &st, "--inputs", inputs], ""); + assert_eq!(c, 2, "{state:.80} {inputs}: {out} {e}"); assert_eq!(s(&parse(&out), &["error", "path"]).as_str(), Some(path), "{inputs}: {out}"); + assert_eq!(read(&st), state, "a refused turn must not write the state"); } + let _ = std::fs::remove_file(&st); +} + +/// The Python wrapper's persona functions (python/test_persona.py), on Python >= 3.9, stdlib only +#[test] +fn python_persona_tests_pass() { + let ok = Command::new("python3").args(["-c", "import sys; sys.exit(0 if sys.version_info >= (3, 9) else 1)"]).output().is_ok_and(|o| o.status.success()); + if !ok { eprintln!("python3 >= 3.9 not found: python/test_persona.py skipped"); return; } + let o = Command::new("python3").arg("test_persona.py").current_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/../python")).env("PROBBIT_BIN", env!("CARGO_BIN_EXE_probbit")).output().unwrap(); + assert!(o.status.success(), "python/test_persona.py failed:\n{}", String::from_utf8_lossy(&o.stderr)); +} diff --git a/probbit-wasm/src/lib.rs b/probbit-wasm/src/lib.rs index 2a12476..0a49540 100644 --- a/probbit-wasm/src/lib.rs +++ b/probbit-wasm/src/lib.rs @@ -1,16 +1,19 @@ //! probbit-wasm: probbit in a browser (wasm32-unknown-unknown), without threads, on the page's clock. JSON in, JSON out, with the //! CLI's own documents: `decide` (a router document, as `probbit decide`), `run` (a probbit-ir program, as `probbit run`), `evaluate` (a -//! System One request, as `probbit evaluate`) and `demo` (as `probbit demo`). Flags go in an optional `flags` object, as in `probbit mcp`: +//! System One request, as `probbit evaluate`), `demo` (as `probbit demo`) and the persona ops `persona_init` / `persona_turn` (the +//! arguments of the MCP tools probbit_persona_init / probbit_persona_turn, with the persona inline; docs/persona.md). Flags go in an +//! optional `flags` object, as in `probbit mcp`: //! `{"workers": [...], "tasks": [...], "flags": {"sweeps": 3200, "polish_ms": 0}}`. //! -//! The code is the CLI's (probbit-cli's json.rs, run.rs, router.rs, evaluate.rs and sys.rs, included below) run with +//! The code is the CLI's (probbit-cli's json.rs, run.rs, router.rs, evaluate.rs, persona.rs, yaml.rs and sys.rs, included below) run with //! `probbit_core::rt::set_sequential(true)`: every chain, gate pass and polish chain runs in order on the calling thread. At fixed //! work the documents equal the CLI's at any `--threads` (timings and `telemetry.threads` aside; tests/no_threads.rs). //! Not here: `--summary`, `--pretty`, `--top`, `--progress`, `--cpu-limit`, `--priority` and the config file (an unknown flag is //! an error). `threads` above 1 is accepted only off wasm (the native tests' comparison runs). //! //! JavaScript calls the C ABI at the end (playground/index.html): `probbit_alloc(len)` -> a buffer for the UTF-8 input; -//! `probbit_call(op, ptr, len)` -> the output's length (op 0 decide, 1 run, 2 evaluate, 3 demo; the input buffer is consumed); +//! `probbit_call(op, ptr, len)` -> the output's length (op 0 decide, 1 run, 2 evaluate, 3 demo, 4 persona init, 5 persona turn; the +//! input buffer is consumed); //! `probbit_out_ptr()` / `probbit_out_code()` -> the output bytes and the CLI's exit code (0 answer, 1 infeasible, 2 bad input or flag, //! 3 refused / declined). The clock is the import `probbit.now_ms` (`performance.now()`). No dependencies (no wasm-bindgen). #[allow(dead_code)] @@ -28,6 +31,12 @@ mod evaluate; #[allow(dead_code, unused_imports)] #[path = "../../probbit-cli/src/sys.rs"] mod sys; +#[allow(dead_code)] +#[path = "../../probbit-cli/src/persona.rs"] +mod persona; +#[allow(dead_code)] +#[path = "../../probbit-cli/src/yaml.rs"] +mod yaml; /// The CLI's `--top` monitor hooks (probbit-cli/src/tui.rs): nothing is drawn here. mod tui { pub fn tier(_: &'static str) {} @@ -64,6 +73,19 @@ pub fn demo(input: &str) -> (String, i32) { match go() { Ok(s) => (s, 0), Err(e) => (json::write(&e, false), 2) } } +/// `probbit persona init` (MCP tool probbit_persona_init): `{"persona": {...}, "seed": 2}` -> the individual's state, exactly the CLI's +/// document (`probbit persona init PERSONA --seed 2`). Without threads; resource controls at the CLI's defaults (they never change it). +pub fn persona_init(input: &str) -> (String, i32) { persona_call(input, "probbit_persona_init") } +/// `probbit persona turn` (MCP tool probbit_persona_turn): `{"persona": {...}, "state": {...}, "inputs": {...}, "flags": {"timing": true}}` +/// -> `{"stance": ..., "state": ...}`, the stance and next state exactly as the CLI prints and writes them. +pub fn persona_turn(input: &str) -> (String, i32) { persona_call(input, "probbit_persona_turn") } +fn persona_call(input: &str, tool: &str) -> (String, i32) { + let eng = |prog: &Json, f: &persona::Flags| on_threads(1, || persona::run_program(prog, f, 1, 100, 1024)); + let r = json::parse(input).map_err(|e| persona::perr(&e.path, e.msg)).and_then(|j| match j { Json::Obj(v) => persona::tool(tool, &v, &eng, false), + _ => Err(persona::perr("", "the arguments must be a JSON object")) }); + match r { Ok(d) => (persona::canon(&d), 0), Err(e) => (persona::canon(&e.to_json()), 2) } +} + /// The flags of one call, with the CLI's defaults and ranges. struct Flags { budget: f64, budget_given: bool, seed: u64, exact_limit: u64, exact_ms: Option, fr_states: usize, polish_ms: f64, polish_sweeps: usize, sweeps: usize, collective: bool, cluster: bool, cycles: bool, chains: usize, threads: usize, mem_mb: usize, mode: String, deadline_ms: Option, program: bool } @@ -171,7 +193,7 @@ fn page_clock() -> f64 { unsafe { now_ms() } } /// A buffer of `len` bytes for `probbit_call`'s input. #[no_mangle] pub extern "C" fn probbit_alloc(len: usize) -> *mut u8 { let mut v = Vec::::with_capacity(len.max(1)); let p = v.as_mut_ptr(); std::mem::forget(v); p } -/// Run op 0 decide, 1 run, 2 evaluate, 3 demo on the UTF-8 JSON at `ptr` (`len` bytes); returns the output's length (read it at +/// Run op 0 decide, 1 run, 2 evaluate, 3 demo, 4 persona init, 5 persona turn on the UTF-8 JSON at `ptr` (`len` bytes); returns the output's length (read it at /// `probbit_out_ptr()`, valid until the next call; the exit code at `probbit_out_code()`). /// /// # Safety @@ -183,7 +205,7 @@ pub unsafe extern "C" fn probbit_call(op: u32, ptr: *mut u8, len: usize) -> usiz probbit_core::rt::set_clock(page_clock); let (out, code) = match std::str::from_utf8(&input) { Err(e) => (json::write(&json::schema("", format!("bad JSON: invalid UTF-8 at byte {}", e.valid_up_to())).to_json(), false), 2), - Ok(s) => match op { 0 => decide(s), 1 => run(s), 2 => evaluate(s), 3 => demo(s), _ => (json::write(&json::value("", format!("unknown op {op}")).to_json(), false), 2) } }; + Ok(s) => match op { 0 => decide(s), 1 => run(s), 2 => evaluate(s), 3 => demo(s), 4 => persona_init(s), 5 => persona_turn(s), _ => (json::write(&json::value("", format!("unknown op {op}")).to_json(), false), 2) } }; let n = out.len(); OUT.with(|o| *o.borrow_mut() = (out.into_bytes(), code)); n } /// The last output's bytes. diff --git a/probbit-wasm/tests/persona.rs b/probbit-wasm/tests/persona.rs new file mode 100644 index 0000000..6185217 --- /dev/null +++ b/probbit-wasm/tests/persona.rs @@ -0,0 +1,36 @@ +//! The browser module's persona ops (`persona_init` / `persona_turn`, C ABI ops 4 / 5) give the CLI's documents without threads: +//! the example personas' goldens (examples/persona/golden/, the `probbit persona` documents) on every workday turn, and the same +//! bytes through `probbit_call`. Documents are canonical JSON, so equal values are equal bytes. +#[allow(dead_code)] +#[path = "../../probbit-cli/src/json.rs"] +mod json; +use json::Json; + +fn read(p: &str) -> String { std::fs::read_to_string(format!("{}/../examples/persona/{p}", env!("CARGO_MANIFEST_DIR"))).unwrap().replace("\r\n", "\n") } +fn parse(s: &str) -> Json { json::parse(s).unwrap_or_else(|e| panic!("not JSON ({}): {s:.200}", e.msg)) } + +#[test] +fn persona_ops_give_the_cli_documents() { + let turns = parse(&read("workday.json")).get("turns").unwrap().as_arr().unwrap().to_vec(); + for n in ["ops-engineer", "tutor", "trader-assistant"] { + let doc = read(&format!("{n}.json")); let g = |f: &str| read(&format!("golden/{n}/{f}")); + let (st, c) = probbit_wasm::persona_init(&format!(r#"{{"persona": {doc}}}"#)); assert_eq!(c, 0, "{st}"); + assert_eq!(format!("{st}\n"), g("state0.json"), "{n} state0"); + let gold = g("workday.jsonl"); let mut state = st; + for (i, (t, line)) in turns.iter().zip(gold.lines()).enumerate() { + let (out, c) = probbit_wasm::persona_turn(&format!(r#"{{"persona": {doc}, "state": {state}, "inputs": {}}}"#, json::write(t, false))); assert_eq!(c, 0, "{out}"); + let o = parse(&out); assert_eq!(o.get("stance"), Some(&parse(line)), "{n} turn {i}"); + state = json::write(o.get("state").unwrap(), false); } + assert_eq!(parse(&state), parse(&g("final-state.json")), "{n} final state"); + } + // the C ABI: op 4 / op 5 are the same functions; a bad input is one persona error object, exit 2 + let call = |op: u32, s: &str| -> (String, i32) { let p = probbit_wasm::probbit_alloc(s.len()); unsafe { std::ptr::copy_nonoverlapping(s.as_ptr(), p, s.len()); + let n = probbit_wasm::probbit_call(op, p, s.len()); (String::from_utf8(std::slice::from_raw_parts(probbit_wasm::probbit_out_ptr(), n).to_vec()).unwrap(), probbit_wasm::probbit_out_code()) } }; + let doc = read("tutor.json"); let arg = format!(r#"{{"persona": {doc}, "seed": 3}}"#); + let (a, c) = call(4, &arg); assert_eq!((a.clone(), c), probbit_wasm::persona_init(&arg)); + let t = format!(r#"{{"persona": {doc}, "state": {a}, "inputs": {{"loss": true}}, "flags": {{"no_inertia": true}}}}"#); + assert_eq!(call(5, &t), probbit_wasm::persona_turn(&t)); + let (e, c) = call(5, &format!(r#"{{"persona": {doc}, "state": {a}, "inputs": {{"stakes": 9}}}}"#)); assert_eq!(c, 2); + assert_eq!(parse(&e).get("error").and_then(|x| x.get("path")).and_then(Json::as_str), Some("inputs.stakes")); + let (e, c) = call(4, r#"{"persona_path": "tutor.yaml"}"#); assert_eq!(c, 2); assert!(e.contains("no files here"), "{e}"); +} diff --git a/python/probbit.py b/python/probbit.py index f9c9e27..09f23fc 100644 --- a/python/probbit.py +++ b/python/probbit.py @@ -8,6 +8,9 @@ answer = probbit.run(program, deadline_ms=1000) # whole-call deadline (the process is also killed past a slack) answer = probbit.decide(router_doc, summary=True) # compact answer: verdict, counts, gate, the worst items (no per-item tables) answer = probbit.evaluate(request, judge="http://127.0.0.1:8080", auth_env="JUDGE_KEY") # a judge's System One answers + rules + state = probbit.persona_init("examples/persona/tutor.yaml", seed=2) # an individual of a persona (docs/persona.md) + turn = probbit.persona_turn(persona, state, {"loss": True}) # {"stance": ..., "state": ...}; put turn["stance"]["line"] in the prompt + trace = probbit.persona_replay(persona, script, seed=2) # init + every turn of a script -> the stances Answers: `probbit run` reports per-variable odds under "marginals", `probbit decide` under "odds" (docs/probbit-ir-json.md, README). Every call returns the decoded JSON answer for exit 0 (exact / diagnostics_passed / partial), exit 1 (`infeasible`, a proof) @@ -22,10 +25,10 @@ cycles take on / off); any other boolean is a switch: summary=True -> --summary, pretty=True -> --pretty, False leaves it out. The binary: `binary=` argument, else $PROBBIT_BIN, else `probbit` on PATH, else ../target/release/probbit next to this file. """ -import json, os, shutil, subprocess, urllib.error, urllib.parse, urllib.request +import json, os, shutil, subprocess, tempfile, urllib.error, urllib.parse, urllib.request -__all__ = ["run", "exact", "sample", "decide", "demo", "evaluate", "find_binary", "ProbbitError", "ProbbitInputError", "ProbbitNumericError", "ProbbitTimeout", - "ProbbitJudgeError"] +__all__ = ["run", "exact", "sample", "decide", "demo", "evaluate", "persona_init", "persona_turn", "persona_replay", "find_binary", "ProbbitError", + "ProbbitInputError", "ProbbitNumericError", "ProbbitTimeout", "ProbbitJudgeError"] class ProbbitError(Exception): @@ -213,3 +216,74 @@ def evaluate(request, judge=None, *, auth_env=None, judge_timeout_s=30, timeout_ raise ProbbitJudgeError(f"the judge returned {type(reply).__name__}, not a System One response or per-question probabilities") req["probbit"] = block return _call(["evaluate"], req, dict(flags, deadline_ms=deadline_ms), _timeout(timeout_s, deadline_ms), binary) + + +# ---------------------------------------------------------------- persona: the individuality layer (docs/persona.md) +def _persona_path(persona, d): + """A persona file path (YAML subset or JSON) as is; a dict (the JSON form of a persona file) written to a file in d.""" + if isinstance(persona, dict): + path = os.path.join(d, "persona.json") + with open(path, "w", encoding="utf-8") as f: + json.dump(persona, f, ensure_ascii=False) + return path + if isinstance(persona, (str, os.PathLike)): + return os.fspath(persona) + raise TypeError("persona must be a file path or a dict (the JSON form of a persona file)") + + +def _json_file(d, name, doc): + path = os.path.join(d, name) + with open(path, "w", encoding="utf-8") as f: + json.dump(doc, f, ensure_ascii=False) + return path + + +def _persona_call(args, timeout_s, binary, lines=False): + try: + p = subprocess.run([find_binary(binary), "persona", *args], capture_output=True, encoding="utf-8", timeout=timeout_s) + except subprocess.TimeoutExpired as e: + raise ProbbitTimeout(f"probbit persona {args[0]} passed timeout_s={timeout_s}") from e + try: + out = [json.loads(x) for x in p.stdout.splitlines()] if lines and p.returncode == 0 else (json.loads(p.stdout) if p.stdout.strip() else None) + except ValueError: + raise ProbbitError(f"unparseable output (exit {p.returncode})", p.returncode, p.stdout, p.stderr) + if p.returncode == 2: + err = out.get("error") if isinstance(out, dict) else None + if err: + raise ProbbitInputError(err.get("code"), err.get("path"), err.get("message"), 2, p.stdout, p.stderr) + raise ProbbitInputError("flag", None, p.stderr.strip().removeprefix("probbit: "), 2, p.stdout, p.stderr) + if p.returncode == 0 and out is not None: + return out + raise ProbbitError(f"probbit persona {args[0]} exited {p.returncode}: {p.stderr.strip()[:300]}", p.returncode, p.stdout, p.stderr) + + +def persona_init(persona, seed=None, *, timeout_s=None, binary=None): + """`probbit persona init`: a new individual of a persona (a file path or the document as a dict) -> its state (a dict to store + and pass to persona_turn). seed: the individual (default: the persona's identity.seed).""" + with tempfile.TemporaryDirectory() as d: + args = ["init", _persona_path(persona, d)] + (["--seed", str(seed)] if seed is not None else []) + return _persona_call(args, timeout_s, binary) + + +def persona_turn(persona, state, inputs=None, *, timeout_s=None, binary=None, **flags): + """`probbit persona turn`: the persona, the individual's state and this turn's inputs -> {"stance": ..., "state": ...}, the same + shape as the MCP tool probbit_persona_turn. Put stance["line"] into the model's prompt and keep the returned state for the next + turn. Flags: timing=True (a non-canonical timing object), no_inertia=True. A bad persona, state or input raises + ProbbitInputError (code "persona", .path, .message); a refused or fallback stance is an answer.""" + with tempfile.TemporaryDirectory() as d: + nxt = os.path.join(d, "next.json") + args = ["turn", _persona_path(persona, d), "--state", _json_file(d, "state.json", state), "--inputs", _json_file(d, "inputs.json", inputs or {}), + "--out", nxt, *_flags(flags)] + stance = _persona_call(args, timeout_s, binary) + with open(nxt, encoding="utf-8") as f: + return {"stance": stance, "state": json.load(f)} + + +def persona_replay(persona, script, seed=None, *, timeout_s=None, binary=None, **flags): + """`probbit persona replay`: init at seed, then every turn of the script (a list of input dicts, or {"turns": [...]}) -> + the list of stances, byte for byte what persona_turn would give turn by turn. Flags: no_inertia=True.""" + with tempfile.TemporaryDirectory() as d: + args = ["replay", _persona_path(persona, d), "--script", _json_file(d, "script.json", script), *_flags(flags)] + if seed is not None: + args += ["--seed", str(seed)] + return _persona_call(args, timeout_s, binary, lines=True) diff --git a/python/test_mcp.py b/python/test_mcp.py index c5722c1..1979c7c 100644 --- a/python/test_mcp.py +++ b/python/test_mcp.py @@ -71,12 +71,12 @@ def call(self, name, arguments, meta=None): self.assertIn("result", r, r) return r["result"] - def test_legacy_handshake_lists_five_tools(self): + def test_legacy_handshake_lists_seven_tools(self): init = self.legacy() self.assertEqual(init["protocolVersion"], "2025-06-18"); self.assertEqual(init["serverInfo"]["name"], "probbit") self.assertIn("tools", init["capabilities"]); self.assertNotIn("resultType", init) tools = self.c.request("tools/list")["result"]["tools"] - self.assertEqual([t["name"] for t in tools], ["probbit_decide", "probbit_run", "probbit_stats", "probbit_demo", "probbit_evaluate"]) + self.assertEqual([t["name"] for t in tools], ["probbit_decide", "probbit_run", "probbit_stats", "probbit_demo", "probbit_evaluate", "probbit_persona_init", "probbit_persona_turn"]) for t in tools: self.assertEqual(t["inputSchema"]["type"], "object"); self.assertTrue(t["description"]) self.assertIn("flags", tools[0]["inputSchema"]["properties"]); self.assertIn("vars", tools[1]["inputSchema"]["properties"]) @@ -134,6 +134,38 @@ def test_evaluate_answers_exactly_as_the_cli(self): bad = self.call("probbit_evaluate", {"questions": {"a": {"type": "boolean"}}}) self.assertTrue(bad["isError"]); self.assertEqual(bad["structuredContent"]["error"], {"code": "value", "path": "questions.a.type", "message": bad["structuredContent"]["error"]["message"]}) + def test_persona_tools_answer_exactly_as_the_cli(self): + self.legacy("2025-11-25") + path = os.path.join(EX, "persona", "tutor.yaml") + with open(os.path.join(EX, "persona", "tutor.json")) as f: + doc = json.load(f) + init = self.call("probbit_persona_init", {"persona": doc, "seed": 2}) + code, out = cli(["persona", "init", path, "--seed", "2"]) + self.assertFalse(init["isError"]); self.assertEqual(code, 0); self.assertEqual(init["content"][0]["text"], out.rstrip("\n")) + state = init["structuredContent"] + import tempfile + with tempfile.TemporaryDirectory() as d: + sp = os.path.join(d, "state.json") + with open(sp, "w") as f: + f.write(out) + for inputs in ({"confused": True, "error": True}, {"loss": True, "sentiment": "negative"}, {}): + r = self.call("probbit_persona_turn", {"persona_path": path, "state": state, "inputs": inputs}) + code, out = cli(["persona", "turn", path, "--state", sp, "--inputs", json.dumps(inputs)]) + self.assertFalse(r["isError"]); self.assertEqual(code, 0) + self.assertEqual(r["structuredContent"]["stance"], json.loads(out)) # MCP answer == CLI answer + with open(sp) as f: + self.assertEqual(r["structuredContent"]["state"], json.load(f)) # and the same next state + self.assertEqual(json.loads(r["content"][0]["text"]), r["structuredContent"]) + state = r["structuredContent"]["state"] + refused = self.call("probbit_persona_turn", {"persona": dict(doc, engine={"op": "sample", "sweeps": 8}), "state": self.call("probbit_persona_init", {"persona": dict(doc, engine={"op": "sample", "sweeps": 8})})["structuredContent"], "inputs": {}}) + self.assertFalse(refused["isError"]); self.assertIn(refused["structuredContent"]["stance"]["status"], ("refused", "partial", "fallback")) # a refusal is an answer + bad = self.call("probbit_persona_turn", {"persona": doc, "state": state, "inputs": {"stakes": 2}}) + self.assertTrue(bad["isError"]); self.assertEqual(bad["structuredContent"]["error"]["code"], "persona"); self.assertEqual(bad["structuredContent"]["error"]["path"], "inputs.stakes") + for args, where in (({"persona": doc, "persona_path": path, "state": state}, "arguments"), ({"persona": doc, "state": state, "extra": 1}, "arguments.extra"), + ({"persona": doc}, "arguments.state"), ({"persona": [1], "state": state}, "arguments.persona")): + e = self.call("probbit_persona_turn", args) + self.assertTrue(e["isError"]); self.assertEqual(e["structuredContent"]["error"]["path"], where) + def test_structured_content_from_2025_06_18_on(self): self.legacy("2024-11-05") r = self.call("probbit_demo", {"tasks": 3}) @@ -144,7 +176,7 @@ def test_modern_requests_carry_their_metadata(self): self.assertEqual(d["resultType"], "complete"); self.assertIn("2026-07-28", d["supportedVersions"]) self.assertEqual(d["_meta"]["io.modelcontextprotocol/serverInfo"]["name"], "probbit"); self.assertIn("tools", d["capabilities"]) t = self.c.request("tools/list", meta=MODERN)["result"] - self.assertEqual(t["resultType"], "complete"); self.assertEqual(len(t["tools"]), 5) + self.assertEqual(t["resultType"], "complete"); self.assertEqual(len(t["tools"]), 7) r = self.call("probbit_demo", {"tasks": 4, "seed": 2}, MODERN) self.assertEqual(r["resultType"], "complete"); self.assertEqual(len(r["structuredContent"]["tasks"]), 4) e = self.c.request("tools/list", meta={"io.modelcontextprotocol/protocolVersion": "1999-01-01", "io.modelcontextprotocol/clientCapabilities": {}})["error"] diff --git a/python/test_persona.py b/python/test_persona.py new file mode 100644 index 0000000..b3be2bb --- /dev/null +++ b/python/test_persona.py @@ -0,0 +1,79 @@ +"""Tests for the persona functions of python/probbit.py (stdlib unittest, Python 3.9; run: python3 python/test_persona.py). +They drive `probbit persona` through the wrapper and check its documents against the goldens in examples/persona/golden/.""" +import json, os, unittest +import probbit + +EX = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "examples", "persona") + + +def load(name): + with open(os.path.join(EX, name), encoding="utf-8") as f: + return json.load(f) + + +def golden(name, f): + with open(os.path.join(EX, "golden", name, f), encoding="utf-8") as fh: + return [json.loads(x) for x in fh.read().splitlines()] if f.endswith(".jsonl") else json.load(fh) + + +class Persona(unittest.TestCase): + def test_init_turn_and_replay_equal_the_goldens(self): + script = load("workday.json")["turns"] + for name in ("ops-engineer", "tutor", "trader-assistant"): + path = os.path.join(EX, name + ".yaml") + st = probbit.persona_init(path) + self.assertEqual(st, golden(name, "state0.json")) + stances = [] + for inputs in script: + r = probbit.persona_turn(path, st, inputs) + stances.append(r["stance"]); st = r["state"] + self.assertEqual(stances, golden(name, "workday.jsonl"), name) + self.assertEqual(st, golden(name, "final-state.json")) + self.assertEqual(probbit.persona_replay(path, {"turns": script}), stances) + + def test_a_dict_persona_is_the_same_individual(self): + doc = load("tutor.json") + a = probbit.persona_init(doc, seed=3); b = probbit.persona_init(os.path.join(EX, "tutor.yaml"), seed=3) + self.assertEqual(a, b); self.assertEqual(a["seed"], 3) + self.assertNotEqual(a["genes"], probbit.persona_init(doc, seed=4)["genes"]) # another seed, another individual + r = probbit.persona_turn(doc, a, {"loss": True, "sentiment": "negative"}, timing=True) + self.assertEqual(r["stance"]["stance"]["humour"]["level"], "none") # the habit: no jokes on a loss + self.assertEqual(r["stance"]["habits"]["violations"], 0); self.assertIn("timing", r["stance"]); self.assertLessEqual(r["stance"]["line_tokens"], 40) + + def test_errors_are_typed(self): + doc = load("ops-engineer.json") + st = probbit.persona_init(doc) + with self.assertRaises(probbit.ProbbitInputError) as e: + probbit.persona_turn(doc, st, {"stakes": 3}) + self.assertEqual((e.exception.code, e.exception.path), ("persona", "inputs.stakes")) + bad = dict(doc, traits=[dict(doc["traits"][0], sprad=1)] + doc["traits"][1:]) + with self.assertRaises(probbit.ProbbitInputError) as e: + probbit.persona_init(bad) + self.assertEqual((e.exception.code, e.exception.path), ("persona", "traits[0].sprad")) + with self.assertRaises(probbit.ProbbitInputError) as e: # a state of another persona + probbit.persona_turn(load("tutor.json"), st, {}) + self.assertEqual(e.exception.path, "state.persona.digest") + edited = dict(st, turn=st["turn"] + 1) + with self.assertRaises(probbit.ProbbitInputError) as e: + probbit.persona_turn(doc, edited, {}) + self.assertEqual(e.exception.path, "state.digest") + + def test_a_refusal_is_an_answer_with_the_habits_only(self): + doc = load("tutor.json"); doc["engine"] = dict(doc.get("engine") or {}, op="sample", sweeps=8) + st = probbit.persona_init(doc) + r = probbit.persona_turn(doc, st, {"loss": True}) + s = r["stance"] + self.assertIn(s["status"], ("refused", "partial", "fallback")); self.assertTrue(s["escalate"]) + unvouched = [t for t, e in s["stance"].items() if not e["released"]] + self.assertTrue(unvouched) + self.assertIn("no jokes, be kind", s["line"]) # a habit in force is always safe to state + + def test_a_state_rewritten_by_python_still_reads(self): + # Python writes 0.0 where the CLI wrote 0: the canonical number rule makes it the same state (same digest) + doc = load("trader-assistant.json") + st = json.loads(json.dumps(probbit.persona_init(doc)), parse_int=float) + self.assertEqual(probbit.persona_turn(doc, st, {"error": True})["stance"]["turn"], 0) + + +if __name__ == "__main__": + unittest.main()