From bb1c844cea0d1646b0ba5fd78ef3e5166bc7e210 Mon Sep 17 00:00:00 2001 From: BitmapAsset Date: Mon, 26 Oct 2026 07:39:36 -0700 Subject: [PATCH 01/61] probbit-persona: the document layer and the strand writer as a library crate (std + no_std) Move json, yaml, persona (with bound and hostlint), run, router and evaluate (feature `decide`), and sys from probbit-cli into a new library crate, probbit-persona, with the pure half of live.rs as `strand` (Live and the line writers, the prev chain, Clock, Replay, verify*, read_actions, first_difference, resume and bound_audit over a &str). The CLI keeps the file half of live.rs (open, append, the writer lock, control_cmd, the MCP tools, the demo week) and imports the rest; the `--top` monitor and the config echo reach the pipeline through `probbit_persona::hooks`. probbit-wasm depends on the crate instead of including the CLI's files. The no_std port, re-based on 0.13.0: features `std` (default) and `nostd` on probbit-core, probbit-ir, probbit-decide and probbit-persona; the probbit-nostd facade (core + alloc + hashbrown + libm + portable-atomic, a console hook); the rt clock and no-thread defaults for not(std); exp/tanh of the kernels through FloatExt; explicit drops where hashbrown's map drop ends a borrow (run.rs twice, evaluate.rs once); persona::load is an error on nostd. Every CLI output is byte for byte as 0.13.0: the P0 corpus 653 outputs, 0 differ; strands-A 726/726 verify with identical outputs; the demo week strand with and without display sha256-identical to the c3018f7 build. --- CHANGELOG.md | 7 + Cargo.lock | 62 ++ Cargo.toml | 2 +- RELEASING.md | 7 +- docs/embedding.md | 91 +++ docs/persona.md | 2 +- docs/probbit-ir-json.md | 4 +- probbit-cli/Cargo.toml | 1 + probbit-cli/src/live.rs | 587 +---------------- probbit-cli/src/main.rs | 17 +- probbit-cli/tests/cli.rs | 2 +- probbit-cli/tests/cli_check.rs | 2 +- probbit-cli/tests/cli_output.rs | 2 +- probbit-cli/tests/evaluate.rs | 2 +- probbit-cli/tests/live.rs | 2 +- probbit-cli/tests/persona.rs | 4 +- probbit-cli/tests/processor_contracts.rs | 2 +- probbit-cli/tests/processor_json.rs | 2 +- probbit-cli/tests/stress.rs | 2 +- probbit-core/Cargo.toml | 7 + probbit-core/src/lib.rs | 33 +- probbit-decide/Cargo.toml | 11 +- probbit-decide/src/ir.rs | 2 + probbit-decide/src/lib.rs | 11 + probbit-decide/src/oracle.rs | 2 + probbit-ir/Cargo.toml | 9 +- probbit-ir/src/lib.rs | 11 + probbit-nostd/Cargo.toml | 20 + probbit-nostd/src/lib.rs | 164 +++++ probbit-persona/Cargo.toml | 25 + {probbit-cli => probbit-persona}/src/bound.rs | 0 .../src/evaluate.rs | 3 + probbit-persona/src/hooks.rs | 34 + .../src/hostlint.rs | 0 {probbit-cli => probbit-persona}/src/json.rs | 2 + probbit-persona/src/lib.rs | 35 + .../src/persona.rs | 9 +- .../src/router.rs | 26 +- {probbit-cli => probbit-persona}/src/run.rs | 22 +- probbit-persona/src/strand.rs | 601 ++++++++++++++++++ {probbit-cli => probbit-persona}/src/sys.rs | 2 + {probbit-cli => probbit-persona}/src/yaml.rs | 2 + probbit-wasm/Cargo.toml | 1 + probbit-wasm/src/lib.rs | 39 +- probbit-wasm/tests/no_threads.rs | 2 +- probbit-wasm/tests/persona.rs | 2 +- 46 files changed, 1198 insertions(+), 677 deletions(-) create mode 100644 docs/embedding.md create mode 100644 probbit-nostd/Cargo.toml create mode 100644 probbit-nostd/src/lib.rs create mode 100644 probbit-persona/Cargo.toml rename {probbit-cli => probbit-persona}/src/bound.rs (100%) rename {probbit-cli => probbit-persona}/src/evaluate.rs (98%) create mode 100644 probbit-persona/src/hooks.rs rename {probbit-cli => probbit-persona}/src/hostlint.rs (100%) rename {probbit-cli => probbit-persona}/src/json.rs (99%) create mode 100644 probbit-persona/src/lib.rs rename {probbit-cli => probbit-persona}/src/persona.rs (99%) rename {probbit-cli => probbit-persona}/src/router.rs (94%) rename {probbit-cli => probbit-persona}/src/run.rs (96%) create mode 100644 probbit-persona/src/strand.rs rename {probbit-cli => probbit-persona}/src/sys.rs (98%) rename {probbit-cli => probbit-persona}/src/yaml.rs (99%) diff --git a/CHANGELOG.md b/CHANGELOG.md index f74ab99..f4c12e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project are documented here. The format follows ## Unreleased +- **`probbit-persona`, the document layer and the strand writer as a library crate** (docs/embedding.md): `json`, `yaml`, + `persona`, `run`, `router` and `evaluate` (feature `decide`), `sys`, and `strand` (the pure half of `probbit live`: `Live`, the + line writers, `Replay`, `verify*`, with no file I/O) move out of probbit-cli, which uses them unchanged; every CLI output is + byte for byte as 0.13.0. probbit-core, probbit-ir, probbit-decide and probbit-persona gain the features `std` (default) and + `nostd` (`no_std` + `alloc`, through the new `probbit-nostd` facade), checked on `xtensa-esp32s3-none-elf`; probbit-wasm + depends on the crate instead of including the CLI's files. + ## 0.13.0 - 2026-10-26 The monitor face and the Mac app, identity labels and many strands in one monitor, and the OpenClaw plugin 0.2.0. A persona diff --git a/Cargo.lock b/Cargo.lock index cbb7e8c..aea55fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,42 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +dependencies = [ + "critical-section", +] + [[package]] name = "probbit-cli" version = "0.13.0" @@ -9,11 +45,15 @@ dependencies = [ "probbit-core", "probbit-decide", "probbit-ir", + "probbit-persona", ] [[package]] name = "probbit-core" version = "0.13.0" +dependencies = [ + "probbit-nostd", +] [[package]] name = "probbit-decide" @@ -21,6 +61,7 @@ version = "0.13.0" dependencies = [ "probbit-core", "probbit-ir", + "probbit-nostd", ] [[package]] @@ -28,6 +69,26 @@ name = "probbit-ir" version = "0.13.0" dependencies = [ "probbit-core", + "probbit-nostd", +] + +[[package]] +name = "probbit-nostd" +version = "0.13.0" +dependencies = [ + "hashbrown", + "libm", + "portable-atomic", +] + +[[package]] +name = "probbit-persona" +version = "0.13.0" +dependencies = [ + "probbit-core", + "probbit-decide", + "probbit-ir", + "probbit-nostd", ] [[package]] @@ -37,4 +98,5 @@ dependencies = [ "probbit-core", "probbit-decide", "probbit-ir", + "probbit-persona", ] diff --git a/Cargo.toml b/Cargo.toml index 43994e6..55670a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["probbit-core", "probbit-ir", "probbit-decide", "probbit-cli", "probbit-wasm"] +members = ["probbit-nostd", "probbit-core", "probbit-ir", "probbit-decide", "probbit-persona", "probbit-cli", "probbit-wasm"] [workspace.package] version = "0.13.0" diff --git a/RELEASING.md b/RELEASING.md index 05fb12b..6b0641c 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -15,7 +15,7 @@ the RFC). `probbit-cli/tests/readme_release.rs` fails when the README's current 1. **Release PR** (branch `release-X.Y.Z`, squash subject `Release probbit X.Y.Z (#N)`), from a green `main`: - `Cargo.toml`'s `version` and the `version` of the internal path dependencies (`probbit-cli`, `probbit-decide`, - `probbit-ir`); `cargo update --workspace --offline` for `Cargo.lock` (CI builds `--locked`); + `probbit-ir`, `probbit-core`, `probbit-persona`: the `probbit-nostd` entries too); `cargo update --workspace --offline` for `Cargo.lock` (CI builds `--locked`); - `cargo build --release --locked -p probbit-cli`, then `target/release/probbit man > docs/probbit.1` (generated, never hand-edited: `probbit-cli/tests/cli_man.rs` compares them); - `npm/package.json`'s `version`; the example tag in `install.sh`, `install.ps1`, `action.yml`, @@ -49,8 +49,9 @@ the RFC). `probbit-cli/tests/readme_release.rs` fails when the README's current `package.json`; no tests), then `npm publish --access public` with the same userconfig; `npm view probbit-stance version` gives the new version. Its `MIN_PROBBIT` (`src/version.ts`) names the oldest probbit it runs. -6. **crates.io**: `cargo publish --workspace --locked` from the tag (`probbit-core`, `probbit-ir`, `probbit-decide`, - `probbit-cli`; `probbit-wasm` has `publish = false`). +6. **crates.io**: `cargo publish --workspace --locked` from the tag, which publishes in dependency order (`probbit-nostd`, + `probbit-core`, `probbit-ir`, `probbit-decide`, `probbit-persona`, `probbit-cli`; `probbit-wasm` has `publish = false`). + `probbit-nostd` goes first: the engine crates name it as an optional dependency with a version. 7. **Back to development**: `main` moves to the next `-dev` version (`Cargo.toml`, the path dependencies, `Cargo.lock`, `docs/probbit.1`); the README goes on naming the released version until the next release PR. diff --git a/docs/embedding.md b/docs/embedding.md new file mode 100644 index 0000000..ec80365 --- /dev/null +++ b/docs/embedding.md @@ -0,0 +1,91 @@ +# Embedding probbit: the `probbit-persona` library, with std or on `no_std` + +The CLI's document layer is a library crate, `probbit-persona`: the JSON and YAML readers, the persona layer (parse, build, +lint, prove, bound, hostlint, the turn, `display.glyphs`), `run` (a probbit-ir JSON program), `router` and `evaluate` (feature +`decide`), and the strand writer and its checks (`strand`). The CLI, probbit-wasm and a firmware run the same code, so a +document or a strand line written by one is the one the others write. + +| feature | default | what it gives | +|---|---|---| +| `std` | yes | builds on std (the CLI, probbit-wasm, any host program) | +| `nostd` | no | builds `no_std` + `alloc` for a target without an operating system; the std subset comes from `probbit-nostd` | +| `decide` | yes | `router` (`probbit decide`) and `evaluate` (the decision-API adapter), through probbit-decide | + +probbit-core, probbit-ir and probbit-decide have the same `std` (default) and `nostd` features. A host program takes the +defaults: + +```toml +[dependencies] +probbit-persona = "0.14" +``` + +## On `no_std` + +```toml +[dependencies] +probbit-persona = { version = "0.14", default-features = false, features = ["nostd"] } # add "decide" for router/evaluate +probbit-core = { version = "0.14", default-features = false, features = ["nostd"] } +probbit-ir = { version = "0.14", default-features = false, features = ["nostd"] } +``` + +The firmware supplies a global allocator (for example `esp-alloc`) and, because `probbit-nostd` builds 64-bit atomics from +`portable-atomic`'s critical-section fallback, a `critical-section` implementation (the HAL's). Checked on +`xtensa-esp32s3-none-elf` (the esp toolchain, `-Zbuild-std=core,alloc`): + +```sh +cargo +esp check -p probbit-persona --no-default-features --features nostd --target xtensa-esp32s3-none-elf -Zbuild-std=core,alloc +``` + +What changes without an operating system: + +- **The facade.** `probbit-nostd` is the subset of `std` the crates use, built from `core`, `alloc`, `hashbrown`, `libm` and + `portable-atomic`. Each crate imports it as `std` on `nostd`, so the code is the same code. Float functions that live in + `std` (`exp`, `ln`, `powf`, `tanh`, ...) come from `libm` through the `FloatExt` trait; `powi` is computed as compiler-rt's + `__powidf2` computes it on the host. `print!`/`println!` go to a console hook: `probbit_nostd::io::set_print(fn(&str))`. +- **The clock.** `probbit_core::rt::Instant` is the clock you install: `probbit_core::rt::set_clock(fn() -> f64)` returning + milliseconds (a firmware passes its system timer). Before any call that measures time, install it. +- **No threads.** `rt::sequential()` is on by default: every chain, gate pass and polish chain runs in order on the calling + thread, as probbit-wasm runs. At fixed work the documents are the ones the CLI writes at any `--threads` (timings and + `telemetry.threads` aside). Set `probbit_ir::GATE_THREADS` to 1 as probbit-wasm does. One core calls the engine. +- **Documents arrive as strings.** There is no file system: `persona::load(path)` returns an error. Read the document with + `persona::parse_doc(text, is_json)` and build it with `persona::build(&doc)`. +- **No usage telemetry.** `sys::usage()` and `sys::nice()` return `None`, so `process_cpu_ms`, `peak_rss_mb` and `nice` are + `null` in telemetry. + +## The strand writer, and how the host checks it + +`probbit_persona::strand` has no file I/O: each call returns the line, and the caller stores it (the CLI appends to a file under +a writer lock, `probbit-cli/src/live.rs`; a firmware writes flash). + +```rust +use probbit_persona::{json, persona, strand::{self, Clock, Live}}; + +// how a turn runs its program: in order on this thread (as the CLI's tests and probbit-wasm run it) +let engine = |prog: &json::Json, f: &persona::Flags| persona::run_program(prog, f, 1, 100, 0); +let doc = persona::parse_doc(PERSONA_YAML, false)?; // Err(String): the reader's message +let p = persona::build(&doc)?; // Err(json::InErr): path, code, message +let st = persona::init(&p, Some(seed), true, &engine); +let (mut live, header) = Live::start(p, &doc, st, Clock::Fixed, &strand::engine()); +store(&header); +let (stance, line) = live.event(&json::parse(r#"{"praise": true, "elapsed_hours": 0.5}"#)?, &engine)?; +store(&line); +if live.n % strand::CHECKPOINT_EVERY == 0 { store(&live.checkpoint(&stance)); } +``` + +- `Clock::Fixed`: each event carries its own `elapsed_hours`, so the strand is a pure function of its events. `Clock::Real` + holds an `rt::Instant` (the installed clock on `nostd`). +- `live.control(what, by, reason, at)` writes pause, resume and retire lines; on `nostd` the caller passes `at` (there is no + `strand::utc_now`). +- Every line carries `prev`, the sha256 of the line before, so the strand is one chain from its header. + +The host reads the strand back (over serial, from flash) and checks it with the same code: `probbit live verify STRAND`, or +`strand::verify(&text, &engine)` in a program. Verify replays every event from the header's persona and initial state and +names the earliest line that differs (a changed, removed or reordered line, or a stance or state digest that the replay does +not reproduce), or the earliest field this engine does not know (`unsupported`: not verified, and not a divergence). +`strand::Replay` steps a strand line by line, and `strand::resume(text, &p, st, clock)` continues one from its last state. + +## The host hooks + +`probbit_persona::hooks` lets a host see the decision pipeline: `set_monitor(tier, gate_seen)` (the CLI's `--top` view) and +`set_config_echo(f)` (the telemetry fields that name the configuration in effect). Unset, the monitor calls do nothing and +the echo is empty. diff --git a/docs/persona.md b/docs/persona.md index 2d8e2f6..7fcbbb8 100644 --- a/docs/persona.md +++ b/docs/persona.md @@ -16,7 +16,7 @@ reads it as its instructions for how to write; an agent can read it to know its 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 +`probbit persona` (probbit-persona/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). `probbit live` (section 5.7, live.rs) keeps an individual running on a clock, with learning (section 2.8) and a replayable log; `probbit monitor` (section 5.8, monitor.rs) diff --git a/docs/probbit-ir-json.md b/docs/probbit-ir-json.md index 5c7246c..c12a81b 100644 --- a/docs/probbit-ir-json.md +++ b/docs/probbit-ir-json.md @@ -123,7 +123,7 @@ size limits in "Limits in v1", stdin above `--max-input-mb`). Unreadable stdin a the offending field (`""` = the whole document, as for JSON syntax errors and non-finite numbers, whose message gives the byte offset). The object goes to stdout when stdout is not a terminal or with `--json` (0.8.2; at a terminal the stderr line alone). Flag errors keep their convention: exit 2, a message on stderr, nothing on stdout (with `--json`: one object, code `flag`). The same strict reader parses -both front-ends (`probbit-cli/src/json.rs`; CLI test `input_contract_rejects_malformed_documents`, which includes the 2026-09-30 external +both front-ends (`probbit-persona/src/json.rs`; CLI test `input_contract_rejects_malformed_documents`, which includes the 2026-09-30 external review's cases: `"allowed": "A"` used to enable every scored worker and answer `exact`; a cap object where the caps array belongs was dropped; duplicate value names emitted duplicate keys). @@ -134,7 +134,7 @@ returns `Err(String)` for structural errors (lengths, out-of-range indices, `i = ## Numeric contract - **Inputs.** Every score / weight (`h`, `potts`, `table`, router `scores` and `affinity`) is a finite number with - |x| <= 1e9 (`limit` error otherwise; `probbit-cli/src/json.rs` `MAX_WEIGHT`). Why 1e9: weights are natural-log odds, and a + |x| <= 1e9 (`limit` error otherwise; `probbit-persona/src/json.rs` `MAX_WEIGHT`). Why 1e9: weights are natural-log odds, and a weight difference beyond ~745 already decides (e^745 is past the range of double-precision probability ratios), so the limit removes no distribution you can express; it keeps every log-space quantity (log w sums over variables, pairs and same-group pairs; log Z) below ~1e9 x (input size), far inside the double range (1.8e308). Caps and limits are integers diff --git a/probbit-cli/Cargo.toml b/probbit-cli/Cargo.toml index 2768a2e..630dd23 100644 --- a/probbit-cli/Cargo.toml +++ b/probbit-cli/Cargo.toml @@ -18,3 +18,4 @@ path = "src/main.rs" probbit-core = { path = "../probbit-core", version = "0.13.0" } probbit-decide = { path = "../probbit-decide", version = "0.13.0" } probbit-ir = { path = "../probbit-ir", version = "0.13.0" } +probbit-persona = { path = "../probbit-persona", version = "0.13.0" } diff --git a/probbit-cli/src/live.rs b/probbit-cli/src/live.rs index c84badd..8d4a83a 100644 --- a/probbit-cli/src/live.rs +++ b/probbit-cli/src/live.rs @@ -9,557 +9,14 @@ //! The safety kit (§5.7): one writer per strand (`STRAND.lock`), control lines (pause, resume, retire: appended by `probbit live //! control`, never by an event; no credit crosses them) and checkpoint lines (the whole state every K events, so a replay can //! start there). `verify` replays all three. +//! +//! The pure half (the line writers, `Live`, `Replay`, `verify`, the chain checks) is `probbit_persona::strand`, so a firmware or +//! another runtime writes and checks the same strand; this file is the CLI's half: strand files, the writer lock, the tools of +//! `probbit mcp` and the demo week. +pub use probbit_persona::strand::*; use crate::json::{self, InErr, Json}; use crate::persona::{self, perr, Engine, Persona, State}; -/// The strand format -const FORMAT: f64 = 1.0; -/// The clock. `Real`: a monotonic clock started with the run; each event's elapsed hours are the time since the previous event -/// (the opening event: since the start). `Fixed`: time comes from the events, each carrying its own `elapsed_hours` (default 0), so a -/// run is a pure function of its events. -pub enum Clock { Real(std::time::Instant), Fixed } -/// Hours quantised to 1e-6 h (3.6 ms): the value the turn uses is the value the strand logs -fn q6(h: f64) -> f64 { persona::r6(h) } - -/// A strand's life as its control lines leave it: events are answered only while `Active` -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum Status { Active, Paused, Retired } -impl Status { - pub fn name(self) -> &'static str { match self { Status::Active => "active", Status::Paused => "paused", Status::Retired => "retired" } } - /// The status a control line leads to: pause an active individual, resume a paused one, retire either; nothing after retire - pub fn after(self, what: &str) -> Result { - match (what, self) { - (_, Status::Retired) => Err(refusal(Status::Retired)), - ("pause", Status::Active) => Ok(Status::Paused), ("resume", Status::Paused) => Ok(Status::Active), ("retire", _) => Ok(Status::Retired), - // RFC 0004 amendment A2: `erase` returns the learned weights to zero and leaves the status as it was - ("erase", s) => Ok(s), - ("pause", _) => Err(perr("control", "already paused")), ("resume", _) => Err(perr("control", "not paused (resume follows a pause)")), - _ => Err(perr("control", "pause | resume | retire")) } - } -} -/// The refusal of an event (or a control line) by a paused or retired individual: code `paused` / `retired` (`live` exits 4) -fn refusal(s: Status) -> InErr { - let msg = if s == Status::Retired { "the individual is retired: every event is refused, for good (probbit live control ... retire)" } - else { "the individual is paused: every event is refused until `probbit live control STRAND resume`" }; - InErr { code: s.name(), path: "strand".into(), msg: msg.into() } -} -/// The default number of events between two checkpoint lines -pub const CHECKPOINT_EVERY: u64 = 1000; - -/// A live individual: the persona, the current state, the clock, the strand's chain and its status (control lines) -pub struct Live { pub p: Persona, pub st: State, clock: Clock, last: f64, prev: String, pub n: u64, pub status: Status, pub controls: u64, pub checkpoints: u64, pub rests: u64, - /// RFC 0009: the expectation's memory (a fold of the logged events) and the last event's exact trajectory (derived, never logged) - pub expect: Option, pub traj: Option } -/// A document with its keys sorted at every level (`persona::text` of it is its canonical JSON) -fn sorted(j: &Json) -> Json { - match j { Json::Obj(v) => { let mut o: Vec<(String, Json)> = v.iter().map(|(k, x)| (k.clone(), sorted(x))).collect(); o.sort_by(|a, b| a.0.cmp(&b.0)); Json::Obj(o) } - Json::Arr(v) => Json::Arr(v.iter().map(sorted).collect()), x => x.clone() } -} -/// One line of a strand without its line ending: strands are written with `\n`; a `\r\n` copy (Windows text mode, an editor) -/// reads the same, since a canonical JSON line never ends in `\r` itself -fn bare(l: &str) -> &str { l.strip_suffix('\r').unwrap_or(l) } -impl Live { - /// Start a life from `st` (an `init` state or a stored one) -> (the individual, the strand's header line). The header keeps - /// the persona document in its own key order (the compiled program follows it, and `engine.program` with it) and the - /// state as canonical JSON (keys sorted), so a state read from a file and the same state from `init` give one header; the - /// header is compact JSON, not key-sorted. - pub fn start(p: Persona, doc: &Json, st: State, clock: Clock, engine: &str) -> (Live, String) { - let s = |x: &str| Json::Str(x.to_string()); - let header = persona::text(&Json::Obj(vec![("probbit_strand".into(), Json::Num(FORMAT)), ("engine".into(), s(engine)), - ("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)), ("state".into(), sorted(&st.to_json(&p))), ("document".into(), doc.clone())])); - let prev = persona::digest_of(&header); - let expect = p.expect.as_ref().map(|_| persona::ExpectMem::new()); - (Live { p, st, clock, last: 0.0, prev, n: 0, status: Status::Active, controls: 0, checkpoints: 0, rests: 0, expect, traj: None }, header) - } - /// One event: stamp its elapsed hours, run the turn, chain the strand line -> (the stance document, the strand line). A bad - /// event is an error and changes nothing (no turn, no line); so is every event while the individual is paused or retired. - pub fn event(&mut self, ev: &Json, eng: Engine) -> Result<(Json, String), InErr> { - if self.status != Status::Active { return Err(refusal(self.status)); } - let Json::Obj(kv) = ev else { return Err(perr("event", "must be a JSON object of inputs")) }; - let mut kv: Vec<(String, Json)> = kv.iter().filter(|(k, _)| k != "elapsed_hours").cloned().collect(); - let given = ev.get("elapsed_hours"); - let (elapsed, now) = match &self.clock { - Clock::Real(t0) => { if given.is_some() { return Err(perr("event.elapsed_hours", "the clock stamps it (--clock fixed reads it from the events)")); } - let now = t0.elapsed().as_secs_f64() / 3600.0; (q6((now - self.last).max(0.0)), now) } - Clock::Fixed => { let e = match given { None => 0.0, Some(Json::Num(x)) if x.is_finite() && *x >= 0.0 => *x, _ => return Err(perr("event.elapsed_hours", "a number >= 0")) }; (q6(e), self.last) } }; - kv.push(("elapsed_hours".into(), Json::Num(elapsed))); - let inputs = Json::Obj(kv); - // RFC 0009: the deviation and the trajectory are read from the state before the event (effects {}: the turn is as without them) - let dev = self.expect.as_ref().and_then(|m| persona::deviation(&self.p, &self.st, &inputs, m, eng)); - let traj = persona::trajectory(&self.p, &self.st, &inputs); - let (mut stance, ns) = persona::turn(&self.p, &self.st, &inputs, false, eng, false)?; - let sd = persona::stance_sha(&stance); - if self.expect.is_some() { if let Json::Obj(v) = &mut stance { v.push(("deviation".into(), dev.as_ref().map_or(Json::Null, persona::Deviation::to_json))); } } - // §2.18: the glyph reads the finished document, `deviation` included (the surprise modifier) - persona::set_glyph(&self.p, &mut stance); - // RFC 0002 part 2: a rest line due before this event is its own line, chained before the event line (the turn applied it) - let (rest, prev) = match persona::rest_line(&self.p, &self.st, &inputs) { - Some((r, rs)) => { let l = rest_line(&self.prev, r, &rs.digest); let d = persona::digest_of(&l); (Some(l), d) } - None => (None, self.prev.clone()) }; - let mut lj = vec![("n".into(), Json::Num((self.n + 1) as f64)), ("inputs".into(), inputs.clone()), ("stance".into(), Json::Str(sd)), - ("state".into(), Json::Str(ns.digest.clone())), ("prev".into(), Json::Str(prev))]; - if let Some(d) = &dev { lj.push(("deviation".into(), d.receipt())); } - if let Some(g) = stance.get("glyph") { lj.push(("glyph".into(), g.clone())); } - let line = persona::canon(&Json::Obj(lj)); - if let Some(m) = self.expect.as_mut() { persona::expect_step(&self.p, m, &inputs, dev.as_ref().and_then(persona::Deviation::logged_pred)); } - self.traj = traj; - self.n += 1; self.last = now; self.prev = persona::digest_of(&line); self.st = ns; - if let Some(r) = &rest { self.rests += 1; return Ok((stance, format!("{r}\n{line}"))); } - Ok((stance, line)) - } - /// A control line (pause, resume or retire) `by` a person or a sensor (`human[:id]`, `env[:id]`) for `reason`, stamped `at`: - /// the status moves, the credit is zeroed (no feedback after the line credits a stance before it) and the line is chained -> - /// the line. Nothing else changes: moods, drives, learned weights and the clock are the individual's as before. - pub fn control(&mut self, what: &str, by: &str, reason: &str, at: &str) -> Result { - let to = self.status.after(what)?; - let line = control_line(&self.prev, what, by, reason, at)?; - self.st = self.st.zero_credit(&self.p); if what == "erase" { self.st = self.st.erased(&self.p); } self.status = to; self.controls += 1; self.prev = persona::digest_of(&line); - Ok(line) - } - /// A checkpoint line after event n: the event count, the whole state after it and that event's stance document, chained -> - /// the line. A replay can start there (the state) and a board can be drawn there (the stance) without the events before. - pub fn checkpoint(&mut self, stance: &Json) -> String { - let line = persona::canon(&Json::Obj(vec![("checkpoint".into(), Json::Num(self.n as f64)), ("prev".into(), Json::Str(self.prev.clone())), ("stance".into(), stance.clone()), - ("state".into(), self.st.to_json(&self.p))])); - self.checkpoints += 1; self.prev = persona::digest_of(&line); line - } - /// The sha256 of the strand's last line (the header's before any event) - pub fn head(&self) -> &str { &self.prev } -} -/// A control line after the line whose sha256 is `prev` (canonical JSON: at, by, control, prev, reason). `by` names who: a -/// person or a sensor, never the individual or the clock; the reason is 1-500 characters. -pub fn control_line(prev: &str, what: &str, by: &str, reason: &str, at: &str) -> Result { - if !["pause", "resume", "retire", "erase"].contains(&what) { return Err(perr("control", "pause | resume | retire")); } - if !matches!(persona::src_kind(by), Some("human" | "env")) { return Err(perr("control.by", "who: human[:id] or env[:id] (id: 1-64 of A-Z a-z 0-9 _ . - @ / :); the individual cannot control itself")); } - if reason.trim().is_empty() || reason.chars().count() > 500 { return Err(perr("control.reason", "1-500 characters")); } - if at.is_empty() || at.len() > 64 { return Err(perr("control.at", "a time stamp, 1-64 characters (RFC 3339 by default)")); } - let s = |x: &str| Json::Str(x.to_string()); - Ok(persona::canon(&Json::Obj(vec![("at".into(), s(at)), ("by".into(), s(by)), ("control".into(), s(what)), ("prev".into(), s(prev)), ("reason".into(), s(reason))]))) -} -/// A rest line (RFC 0002 part 2) after the line whose sha256 is `prev` (canonical JSON: prev, rest, state): what the line did -/// (downscale, prune, the pressure, the learned weights after it, each gate as kept) and the digest of the state after it. It is -/// written only together with the event it precedes, and that event's line chains to it. -fn rest_line(prev: &str, rest: Json, state: &str) -> String { - persona::canon(&Json::Obj(vec![("prev".into(), Json::Str(prev.into())), ("rest".into(), rest), ("state".into(), Json::Str(state.into()))])) -} -/// What kind of strand line `j` is -enum Kind { Event, Control, Checkpoint, Rest } -fn kind(j: &Json) -> Kind { if j.get("control").is_some() { Kind::Control } else if j.get("checkpoint").is_some() { Kind::Checkpoint } else if j.get("rest").is_some() { Kind::Rest } else { Kind::Event } } -/// The header's keys, as this engine writes them -const HEADER: [&str; 6] = ["probbit_strand", "engine", "persona", "seed", "state", "document"]; -/// The earliest key of a line after the header that its kind does not have (the line's keys in canonical order): a line kind -/// this engine does not know (a line without `control` or `checkpoint` is read as an event line, so `{"prev": ..., "nap": ...}` -/// gives `nap`) or a field it does not know on a known one -fn unknown_key(j: &Json) -> Option<&str> { - let known: &[&str] = match kind(j) { Kind::Event => &["deviation", "glyph", "inputs", "n", "prev", "stance", "state"], Kind::Control => &["at", "by", "control", "prev", "reason"], - Kind::Checkpoint => &["checkpoint", "prev", "stance", "state"], Kind::Rest => &["prev", "rest", "state"] }; - j.as_obj()?.iter().map(|(k, _)| k.as_str()).find(|k| !known.contains(k)) -} -/// The start of a replay's `why` that is not a divergence: the line has a field or a line kind this engine does not know (a -/// strand from a newer engine). Nothing from that line on was checked: the strand is not verified, and not shown to differ. -/// `verify` reports it as `unsupported` (exit 5), never as `diverges`; a changed value of a field this engine knows still -/// diverges. -pub const UNSUPPORTED: &str = "unsupported by this engine: "; -/// The `why` of an unsupported field: UNSUPPORTED, then its path (a persona field as the persona validator names it, a state -/// field under `state.`, otherwise its path in the strand line) -fn unsupported(path: &str) -> String { format!("{UNSUPPORTED}{path}") } -/// The field path of an unsupported `why`; None for a divergence -pub fn unsupported_field(why: &str) -> Option<&str> { why.strip_prefix(UNSUPPORTED) } -/// A persona, state or event error for a field this engine does not know (the validators' "unknown field" at its path) -> the -/// unsupported `why`; any other error -> `what` + the error, a divergence -fn read_err(e: InErr, what: &str) -> String { if e.msg.starts_with("unknown field") { unsupported(&e.path) } else { format!("{what}{}: {}", e.path, e.msg) } } -/// Now as RFC 3339 UTC to the second (the control lines' default `at`) -pub fn utc_now() -> String { - let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); - let (z, t) = ((secs / 86_400) as i64 + 719_468, secs % 86_400); // days since 0000-03-01 (civil-from-days) - let (era, doe) = (z.div_euclid(146_097), z.rem_euclid(146_097)); let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; let y = yoe + era * 400 + i64::from(m <= 2); - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", t / 3600, t % 3600 / 60, t % 60) -} - -/// A strand replayed line by line: `verify` and `probbit monitor` (§5.8) share it, so both read a strand with one set of -/// rules. `open` checks the header and rebuilds the individual; each `step` replays one event line on the fixed clock and -/// checks it (`prev`, `n`, the stance and state digests, the bytes). After an error the replay is spent: the chain past the -/// line that differs cannot be checked. -pub struct Replay { pub live: Live, pub header: Json, pub doc: Json, pub line: usize, pub from: Option, last: Option, checkpoint_ready: bool, norms: Normals, at_cap: Vec, seen: (f64, Vec), viab: Option<(f64, Option)>, rest: Option } -/// A senses block's normals over the replay (§2.11): per sense the means after the last 50 events, the largest move of a mean -/// within 50 events so far, and the latest event's move (the monitor's alert: over drift_cap / 2) -#[derive(Default)] -pub struct Normals { window: Vec>, pub max_move: Vec, pub moved: Vec } -impl Normals { - fn start(p: &Persona, st: &State) -> Normals { - let n = persona::normals(p, st); - Normals { window: n.iter().map(|x| std::collections::VecDeque::from(vec![x.1])).collect(), max_move: vec![0.0; n.len()], moved: vec![0.0; n.len()] } - } - fn track(&mut self, p: &Persona, st: &State) { - for (k, (_, m, _, _)) in persona::normals(p, st).iter().enumerate() { let q = &mut self.window[k]; - let mv = persona::r6(q.iter().fold(0.0f64, |a, x| a.max((m - x).abs()))); - self.moved[k] = mv; self.max_move[k] = self.max_move[k].max(mv); q.push_back(*m); if q.len() > 50 { q.pop_front(); } } - } -} -impl Replay { - /// The header line (without its line ending) -> the replay, ready for event lines; Err((1, what differs)), or Err((1, the - /// unsupported `why`)) for a header key, persona field or state field this engine does not know - pub fn open(head: &str) -> Result { - let h = json::parse(head).map_err(|e| (1, format!("the header is not JSON: {}", e.msg)))?; - if h.get("probbit_strand").and_then(Json::as_f64) != Some(FORMAT) { return Err((1, "not a probbit strand (format 1)".into())); } - if let Some((k, _)) = h.as_obj().and_then(|kv| kv.iter().find(|(k, _)| !HEADER.contains(&k.as_str()))) { return Err((1, unsupported(k))); } - let doc = h.get("document").cloned().ok_or((1, "the header has no persona document".to_string()))?; - let p = persona::build(&doc).map_err(|e| (1, read_err(e, "the persona document: ")))?; - if h.get("persona").and_then(|x| x.get("digest")).and_then(Json::as_str) != Some(p.digest.as_str()) { return Err((1, "the persona digest does not match the document".into())); } - let st0 = State::read(&p, h.get("state").unwrap_or(&Json::Null)).map_err(|e| (1, read_err(e, "the initial state: ")))?; - let (live, header) = Live::start(p, &doc, st0, Clock::Fixed, h.get("engine").and_then(Json::as_str).unwrap_or("")); - if header != head { return Err((1, "the header is not as written".into())); } - let norms = Normals::start(&live.p, &live.st); let at_cap = vec![0.0; persona::deficits(&live.p, &live.st).len()]; let seen = (0.0, vec![0.0; persona::gate_gains(&live.p, &live.st).len()]); - let viab = viab_start(&live); - Ok(Replay { live, header: h, doc, line: 1, from: None, last: None, checkpoint_ready: false, norms, at_cap, seen, viab, rest: None }) - } - /// The replay from a checkpoint line (`cp`, without its line ending, at 1-based line `at`; `before` = the event line before it) - /// instead of the header: the header is read as by `open`; the checkpoint must follow `before` (prev), at its event count, with - /// its stance (the digest `before` logs) and its state (read as any state is, with the digest `before` logs); the chain goes on - /// from the checkpoint line. The lines before it are taken as written: a full `verify` replays them and checks the checkpoint. - pub fn open_at(head: &str, before: &str, cp: &str, at: usize) -> Result { - let mut r = Replay::open(head)?; - // RFC 0009: the expectation's memory is a fold of every event line, so a strand with the block replays from its header - if r.live.p.expect.is_some() { return Err((at, "the persona's expectation block reads every event before this checkpoint: verify this strand from its header".into())); } - let j = json::parse(cp).map_err(|e| (at, format!("not JSON: {}", e.msg)))?; - let b = json::parse(before).map_err(|e| (at - 1, format!("not JSON: {}", e.msg)))?; - if persona::canon(&j) != cp { return Err((at, "not canonical JSON".into())); } - if let Some(k) = unknown_key(&j) { return Err((at, unsupported(k))); } - if !j.as_obj().is_some_and(|kv| kv.len() == 4 && kv.iter().all(|(k, _)| ["checkpoint", "prev", "stance", "state"].contains(&k.as_str()))) { - return Err((at, "checkpoint fields must be checkpoint, prev, stance and state".into())); - } - // §2.18: an event line of a persona with glyphs also carries `glyph` - if !b.as_obj().is_some_and(|kv| kv.len() == 5 + usize::from(kv.iter().any(|(k, _)| k == "glyph")) && kv.iter().all(|(k, _)| ["n", "inputs", "prev", "stance", "state", "glyph"].contains(&k.as_str()))) { - return Err((at - 1, "a checkpoint must immediately follow an event line".into())); - } - if j.get("prev").and_then(Json::as_str) != Some(persona::digest_of(before).as_str()) { return Err((at, "prev is not the sha256 of the line before".into())); } - let n = j.get("checkpoint").and_then(Json::as_f64).filter(|x| *x >= 1.0 && x.fract() == 0.0).ok_or((at, "not a checkpoint line".to_string()))? as u64; - if b.get("n").and_then(Json::as_f64) != Some(n as f64) { return Err((at, format!("the line before is not event {n}"))); } - let stance = j.get("stance").cloned().unwrap_or(Json::Null); - if b.get("stance").and_then(Json::as_str) != Some(persona::stance_sha(&stance).as_str()) { return Err((at, "the checkpoint's stance is not the one event {n} logs".replace("{n}", &n.to_string()))); } - let st = State::read(&r.live.p, j.get("state").unwrap_or(&Json::Null)).map_err(|e| (at, read_err(e, "the checkpoint's state: ")))?; - if b.get("state").and_then(Json::as_str) != Some(st.digest.as_str()) { return Err((at, format!("the checkpoint's state is not the one event {n} logs"))); } - r.live.st = st; r.live.n = n; r.live.prev = persona::digest_of(cp); r.live.checkpoints = 1; r.line = at; r.from = Some(n); r.last = Some(stance); r.checkpoint_ready = false; - r.norms = Normals::start(&r.live.p, &r.live.st); r.at_cap = vec![0.0; r.at_cap.len()]; r.seen = (0.0, vec![0.0; r.seen.1.len()]); r.viab = viab_start(&r.live); - Ok(r) - } - /// The stance document of the last event replayed (or of the checkpoint the replay started from) - pub fn last_stance(&self) -> Option<&Json> { self.last.as_ref() } - /// The senses' normals over the replay so far (§2.11) - pub fn normals(&self) -> &Normals { &self.norms } - /// One line (without its line ending) -> the stance document an event line replays to (None for a control or checkpoint - /// line); Err((its 1-based line number, what differs)). A control line must be a valid move of the status and the line its - /// fields give; a checkpoint line must immediately follow an event while active and carry - /// the event count and the state the replay reached. The last stance remains available to - /// the monitor after controls/checkpoints, but that does not authorize another checkpoint. A chained line with a key its - /// kind does not have (a newer line kind, a newer field), or an event whose inputs have a field this engine does not know, - /// is Err((its line number, the unsupported `why`)). - pub fn step(&mut self, l: &str, eng: Engine) -> Result, (usize, String)> { - let (i, live) = (self.line + 1, &mut self.live); - let j = json::parse(l).map_err(|e| (i, format!("not JSON: {}", e.msg)))?; - if persona::canon(&j) != l { return Err((i, "not canonical JSON".into())); } - let head = self.rest.as_deref().map_or(live.head().to_string(), persona::digest_of); - if j.get("prev").and_then(Json::as_str) != Some(head.as_str()) { return Err((i, PREV.into())); } - if let Some(k) = unknown_key(&j) { return Err((i, unsupported(k))); } - if self.rest.is_some() && !matches!(kind(&j), Kind::Event) { return Err((i, "a rest line must be followed by its event line".into())); } - match kind(&j) { - // RFC 0002 part 2: a rest line is checked with the event after it (the replay recomputes it there) - Kind::Rest => { if live.status != Status::Active { return Err((i, format!("rest line while {}", live.status.name()))); } - self.rest = Some(l.to_string()); self.line = i; self.checkpoint_ready = false; return Ok(None) } - Kind::Control => { let f = |k: &str| j.get(k).and_then(Json::as_str).unwrap_or(""); - let line = live.control(f("control"), f("by"), f("reason"), f("at")).map_err(|e| (i, format!("control line: {}: {}", e.path, e.msg)))?; - if line != l { return Err((i, "the control line differs".into())); } - self.line = i; self.checkpoint_ready = false; return Ok(None) } - Kind::Checkpoint => { - if live.status != Status::Active { return Err((i, format!("checkpoint while {}", live.status.name()))); } - if !self.checkpoint_ready { return Err((i, "a checkpoint must immediately follow an event line".into())); } - if j.get("checkpoint").and_then(Json::as_f64) != Some(live.n as f64) { return Err((i, format!("the checkpoint is not at event {}", live.n))); } - let Some(stance) = self.last.as_ref() else { return Err((i, "a checkpoint follows an event line".into())) }; - if live.checkpoint(stance) != l { return Err((i, "the checkpoint's state or stance differs from the replay".into())); } - self.line = i; self.checkpoint_ready = false; return Ok(None) } - Kind::Event => {} } - if j.get("n").and_then(Json::as_f64) != Some((live.n + 1) as f64) { return Err((i, format!("n is not {}", live.n + 1))); } - let ev = j.get("inputs").ok_or((i, "no inputs".to_string()))?; - // a needs block (RFC 0002): the stamped hours of events that found a deficit at its cap (it sat there since the event before) - let before = persona::deficits(&live.p, &live.st); let h = ev.get("elapsed_hours").and_then(Json::as_f64).unwrap_or(0.0); - let logged = self.rest.take(); - let (stance, both) = live.event(ev, eng).map_err(|e| (i, read_err(e, "")))?; - let line = match (both.split_once('\n'), logged) { - (Some((r, e)), Some(lr)) => { if r != lr { return Err((i - 1, "the rest line differs".into())); } e.to_string() } - (Some(_), None) => return Err((i, "a rest line is due before this event and missing".into())), - (None, Some(_)) => return Err((i - 1, "a rest line where none is due".into())), - (None, None) => both }; - for (k, (_, x, cap)) in before.iter().enumerate() { if *x >= cap - 5e-7 { self.at_cap[k] = persona::r6(self.at_cap[k] + h); } } - if j.get("stance").and_then(Json::as_str) != Some(persona::stance_sha(&stance).as_str()) { return Err((i, "the stance differs".into())); } - // RFC 0009: the logged deviation is recomputed (an edited receipt diverges here) - if json::parse(&line).ok().and_then(|x| x.get("deviation").cloned()) != j.get("deviation").cloned() { return Err((i, "the deviation differs".into())); } - // §2.18: the logged glyph is recomputed from the replayed stance (an edited glyph diverges here) - if stance.get("glyph") != j.get("glyph") { return Err((i, "the glyph differs".into())); } - if j.get("state").and_then(Json::as_str) != Some(live.st.digest.as_str()) { return Err((i, "the state differs".into())); } - if line != l { return Err((i, "the line differs".into())); } - self.line = i; self.last = Some(stance.clone()); self.checkpoint_ready = true; self.norms.track(&self.live.p, &self.live.st); - // RFC 0002: the largest R̄ (Design C) and per learned trait the largest gain (Design B) after any event replayed - if let Some((r, _, _)) = persona::pace_rbar(&self.live.p, &self.live.st) { self.seen.0 = self.seen.0.max(r); } - for (k, (_, g, _)) in persona::gate_gains(&self.live.p, &self.live.st).iter().enumerate() { if let Some(m) = self.seen.1.get_mut(k) { *m = m.max(*g); } } - // RFC 0008 E: the smallest margin_min after any event replayed, and the event at which viability was lost - if let (Some((seen, lost_at)), Some((_, m, lost))) = (self.viab.as_mut(), persona::viability(&self.live.p, &self.live.st)) { - *seen = seen.min(m); if lost && lost_at.is_none() { *lost_at = Some(self.live.n); } } - Ok(Some(stance)) - } - /// The end of the strand: a rest line (RFC 0002 part 2) is only ever written with the event after it, so a strand that ends - /// on one diverges at it - pub fn end(&self) -> Result<(), (usize, String)> { - if self.rest.is_some() { return Err((self.line, "a rest line must be followed by its event line".into())); } Ok(()) - } - /// `verify`'s summary of the lines replayed so far: `engine` is the strand header's (the engine that wrote it), `verified_by` - /// the engine that replayed it - pub fn summary(&self) -> Json { - let (s, live) = (|x: &str| Json::Str(x.to_string()), &self.live); - let mut v = vec![("ok".into(), Json::Bool(true)), ("events".into(), Json::Num(live.n as f64)), ("persona".into(), s(&live.p.name)), ("seed".into(), Json::Num(live.st.seed as f64)), - ("engine".into(), self.header.get("engine").cloned().unwrap_or(Json::Null)), ("verified_by".into(), Json::Str(engine())), ("final_state".into(), s(&live.st.digest)), - ("last_line".into(), s(live.head()))]; - // a strand with control or checkpoint lines says so (a strand without them gives the summary of before) - if live.controls > 0 { v.push(("controls".into(), Json::Num(live.controls as f64))); v.push(("status".into(), s(live.status.name()))); } - if live.checkpoints > 0 { v.push(("checkpoints".into(), Json::Num(live.checkpoints as f64))); } - // RFC 0002 part 2: rest lines replayed, the pressure now; the load now - if live.rests > 0 { v.push(("rest_lines".into(), Json::Num(live.rests as f64))); } - if let Some((sp, pend)) = persona::rest_pressure(&live.st) { v.push(("pressure".into(), Json::Obj(vec![("s".into(), Json::Num(sp)), ("pending".into(), Json::Bool(pend))]))); } - if let Some((l, _)) = persona::load_level(&live.p, &live.st) { v.push(("load".into(), Json::Obj(vec![("l".into(), Json::Num(l))]))); } - if let Some(n) = self.from { v.push(("from_checkpoint".into(), Json::Num(n as f64))); } - // a senses block (§2.11): per sense the normal now and the largest move of its mean within 50 events - let nm = persona::normals(&live.p, &live.st); - if !nm.is_empty() { v.push(("normals".into(), Json::Obj(nm.iter().enumerate().map(|(k, (id, m, n, _))| (id.clone(), Json::Obj(vec![("mean".into(), Json::Num(*m)), - ("n".into(), Json::Num(*n as f64)), ("max_move_50".into(), Json::Num(self.norms.max_move.get(k).copied().unwrap_or(0.0)))]))).collect()))); } - // a needs block (RFC 0002): per need its deficit now and the stamped hours it sat at its cap - let dx = persona::deficits(&live.p, &live.st); - if !dx.is_empty() { v.push(("needs".into(), Json::Obj(dx.iter().enumerate().map(|(k, (id, x, _))| (id.clone(), Json::Obj(vec![("deficit".into(), Json::Num(*x)), - ("at_cap_hours".into(), Json::Num(self.at_cap.get(k).copied().unwrap_or(0.0)))]))).collect()))); } - // RFC 0002 Design C: R̄ now and the largest after any event replayed; Design B: per learned trait the largest gain seen - if let Some((r, _, _)) = persona::pace_rbar(&live.p, &live.st) { v.push(("pace".into(), Json::Obj(vec![("rbar".into(), Json::Num(r)), ("max".into(), Json::Num(self.seen.0))]))); } - let gg = persona::gate_gains(&live.p, &live.st); - if !gg.is_empty() { v.push(("surprise".into(), Json::Obj(gg.iter().enumerate().map(|(k, (t, _, _))| (t.clone(), - Json::Obj(vec![("g_max_seen".into(), Json::Num(self.seen.1.get(k).copied().unwrap_or(0.0)))]))).collect()))); } - // RFC 0008 E: with a bound, recomputed from the logged readings and amounts - if let Some((seen, lost_at)) = self.viab { v.push(("viability".into(), Json::Obj(vec![("margin_min_seen".into(), Json::Num(seen)), ("lost_at".into(), lost_at.map_or(Json::Null, |n| Json::Num(n as f64)))]))); } - // a credit block (§2.8): the decisions still awaiting an outcome - if let Some(o) = persona::open_decisions(&live.p, &live.st) { v.push(("open".into(), Json::Arr(o.iter().map(|(t, _)| Json::Num(*t as f64)).collect()))); } - Json::Obj(v) - } -} -/// RFC 0008 E: the viability record a replay starts from: the margin_min of its first state, and lost_at (the event count when the -/// state is already lost, as after a checkpoint); none without a bound -fn viab_start(live: &Live) -> Option<(f64, Option)> { persona::viability(&live.p, &live.st).map(|(_, m, lost)| (m, lost.then_some(live.n))) } -/// Per event line its n and its block -pub type Blocks = Vec<(u64, String)>; -/// `live verify --blocks`: replay from the header -> (the summary, per event line its n and the block its stance renders, RFC 0008 E) -pub fn verify_blocks(text: &str, eng: Engine) -> Result<(Json, Blocks), (usize, String)> { - let lines = lines(text); - let replay = || -> Result<(Json, Blocks), (usize, String)> { - let mut r = Replay::open(lines.first().copied().unwrap_or(""))?; let mut out = vec![]; - for l in lines.iter().skip(1) { if let Some(doc) = r.step(l, eng)? { out.push((r.live.n, persona::block(&r.live.p, &doc))); } } - Ok((r.summary(), out)) }; - replay().map_err(|e| chain_first(&lines, e)) -} -/// A strand's text -> its lines without their line endings (a final newline ends the last line; it does not start another). -/// One leading UTF-8 byte-order mark is skipped, as `\r` before `\n` is: a Windows editor adds both on save, and neither is -/// part of the line text the chain hashes. -pub fn lines(text: &str) -> Vec<&str> { - let mut lines: Vec<&str> = text.strip_prefix('\u{feff}').unwrap_or(text).split('\n').map(bare).collect(); - if lines.last() == Some(&"") { lines.pop(); } - lines -} -/// `probbit live verify`: replay a strand from its header -> Ok(summary) when every line is as recorded; Err((1-based line -/// number, what differs)) at the earliest line that is not. Lines end in `\n` or `\r\n`; the chain hashes the line text alone. -/// A line with a field or line kind this engine does not know stops the replay as unsupported, unless a later line breaks the -/// chain (`chain_first`). -pub fn verify(text: &str, eng: Engine) -> Result { - let lines = lines(text); - let replay = || -> Result { - let mut r = Replay::open(lines.first().copied().unwrap_or(""))?; - for l in lines.iter().skip(1) { r.step(l, eng)?; } - r.end()?; Ok(r.summary()) }; - replay().map_err(|e| chain_first(&lines, e)) -} -/// The divergence the hash chain shows after an unsupported line: a strand this engine cannot replay to the end is still checked -/// for what the format alone says, so an edited, removed or reordered line after it (or the unsupported line itself, edited: -/// the next line's `prev`) diverges at the earliest line that breaks the chain (not JSON, not canonical JSON, or a `prev` that is -/// not the sha256 of the line before), as in a replay. `unsupported` is left only for a strand whose chain is whole; a -/// divergence passes unchanged. -fn chain_first(lines: &[&str], (line, why): (usize, String)) -> (usize, String) { - if unsupported_field(&why).is_none() { return (line, why); } - for i in line.max(1)..lines.len() { - let j = match json::parse(lines[i]) { Ok(j) => j, Err(e) => return (i + 1, format!("not JSON: {}", e.msg)) }; - if persona::canon(&j) != lines[i] { return (i + 1, "not canonical JSON".into()); } - if j.get("prev").and_then(Json::as_str) != Some(persona::digest_of(lines[i - 1]).as_str()) { return (i + 1, PREV.into()); } - } - (line, why) -} -/// The divergence of a line whose `prev` is not the sha256 of the line before -const PREV: &str = "prev is not the sha256 of the line before (a line before it was changed, removed or reordered)"; -/// The 0-based index of the strand's last checkpoint line followed by at least `after` lines, if any (a cheap scan: the key) -pub fn last_checkpoint(lines: &[&str], after: usize) -> Option { - (1..lines.len().saturating_sub(after)).rev().find(|&i| lines[i].starts_with(r#"{"checkpoint":"#)) -} -/// `verify --from-checkpoint`: replay from the last checkpoint line (the header read as always), not from the header -pub fn verify_from_checkpoint(text: &str, eng: Engine) -> Result { - let lines = lines(text); - let Some(k) = last_checkpoint(&lines, 0) else { return verify(text, eng) }; - let replay = || -> Result { - let mut r = Replay::open_at(lines[0], lines[k - 1], lines[k], k + 1)?; - for l in lines.iter().skip(k + 1) { r.step(l, eng)?; } - r.end()?; Ok(r.summary()) }; - replay().map_err(|e| chain_first(&lines, e)) -} -/// `verify` from the header, keeping the stance documents of the given turns (what `--actions` compares with) -pub fn verify_keep(text: &str, eng: Engine, keep: &[u64]) -> Result<(Json, Vec<(u64, Json)>, Persona), (usize, String)> { - let lines = lines(text); - let replay = || -> Result<(Json, Vec<(u64, Json)>, Persona), (usize, String)> { - let mut r = Replay::open(lines.first().copied().unwrap_or(""))?; let mut kept = vec![]; - for l in lines.iter().skip(1) { if let Some(doc) = r.step(l, eng)? { - if let Some(t) = doc.get("turn").and_then(Json::as_f64).map(|x| x as u64).filter(|t| keep.contains(t)) { kept.push((t, doc)); } } } - r.end()?; Ok((r.summary(), kept, r.live.p.clone())) }; - replay().map_err(|e| chain_first(&lines, e)) -} -/// One line of a host's action log (`live verify --actions FILE`, §5.7): the decision's turn and, per trait the host acted on, -/// the level it acted on -pub struct Action { pub line: usize, pub turn: u64, pub levels: Vec<(String, String)> } -/// A host's action log: one JSON object per line, {"turn": T, "action": {trait: level, ...}} (blank lines skipped) -pub fn read_actions(text: &str) -> Result, InErr> { - let mut out = vec![]; - for (i, l) in text.split('\n').map(bare).enumerate() { if l.trim().is_empty() { continue; } let path = format!("actions.line{}", i + 1); - let bad = |m: &str| perr(&path, m.to_string()); - let j = json::parse(l).map_err(|e| perr(&path, format!("not JSON: {}", e.msg)))?; - let kv = j.as_obj().filter(|kv| kv.len() == 2 && kv.iter().all(|(k, _)| k == "turn" || k == "action")).ok_or_else(|| bad("an object {\"turn\": T, \"action\": {trait: level}}"))?; - let turn = kv.iter().find(|(k, _)| k == "turn").and_then(|(_, x)| x.as_f64()).filter(|x| *x >= 0.0 && x.fract() == 0.0 && *x <= 9_007_199_254_740_992.0) - .ok_or_else(|| bad("turn: a whole number >= 0 (the stance's turn)"))? as u64; - let act = kv.iter().find(|(k, _)| k == "action").and_then(|(_, x)| x.as_obj()).filter(|a| !a.is_empty()).ok_or_else(|| bad("action: an object trait -> level"))?; - let levels = act.iter().map(|(t, x)| x.as_str().map(|l| (t.clone(), l.to_string())).ok_or_else(|| bad("action: each level a string"))).collect::, InErr>>()?; - out.push(Action { line: i + 1, turn, levels }); } - Ok(out) -} -/// The earliest turn (then the earliest line) whose host action differs from the level its stance released -> (actions -/// checked, the difference: {turn, line, trait, action, released (null: not released or no such turn), why}); an action on a -/// trait the persona does not have is an input error -pub fn first_difference(p: &Persona, actions: &[Action], stances: &[(u64, Json)]) -> Result<(usize, Option), InErr> { - let mut order: Vec<&Action> = actions.iter().collect(); order.sort_by_key(|a| (a.turn, a.line)); - let s = |x: &str| Json::Str(x.to_string()); let mut checked = 0; - for a in order { checked += 1; let doc = stances.iter().find(|(t, _)| *t == a.turn).map(|x| &x.1); - for (t, level) in &a.levels { - let e = match doc { Some(d) => persona::doc_entry(p, d, t), None => None }; - if doc.is_some() && e.is_none() { return Err(perr(&format!("actions.line{}.action.{t}", a.line), "not a trait of the persona")); } - let released = e.as_ref().filter(|e| e.get("released") == Some(&Json::Bool(true))).and_then(|e| e.get("level")).and_then(Json::as_str).map(str::to_string); - let why = match (doc, &released) { (None, _) => "the strand has no stance with this turn", (_, None) => "the stance did not release this trait", - (_, Some(r)) if r != level => "the host acted on another level", _ => continue }; - return Ok((checked, Some(Json::Obj(vec![("turn".into(), Json::Num(a.turn as f64)), ("line".into(), Json::Num(a.line as f64)), ("trait".into(), s(t)), ("action".into(), s(level)), - ("released".into(), released.map_or(Json::Null, |r| s(&r))), ("why".into(), s(why))])))); } } - Ok((checked, None)) -} -/// `verify` as one document: the summary, or `failure`'s document -pub fn verify_doc(text: &str, eng: Engine) -> Json { verify(text, eng).unwrap_or_else(|(line, why)| failure(text, line, &why)) } -/// The document of a strand that does not verify at `line`: {ok: false, line, diverges: what differs}, or, for a field or line -/// kind this engine does not know, {ok: false, line, unsupported: the field's path, engine: this engine, strand_engine: the -/// header's `engine` (null if it names none)} -pub fn failure(text: &str, line: usize, why: &str) -> Json { - let mut v = vec![("ok".into(), Json::Bool(false)), ("line".into(), Json::Num(line as f64))]; - match unsupported_field(why) { - Some(f) => { v.push(("unsupported".into(), Json::Str(f.to_string()))); v.push(("engine".into(), Json::Str(engine()))); v.push(("strand_engine".into(), strand_engine(text))); } - None => v.push(("diverges".into(), Json::Str(why.to_string()))) } - Json::Obj(v) -} -/// The engine a strand's header names: its `engine` string, or null when the header is not JSON or has none -pub fn strand_engine(text: &str) -> Json { - json::parse(lines(text).into_iter().next().unwrap_or("")).ok().and_then(|h| h.get("engine").cloned()).filter(|e| matches!(e, Json::Str(_))).unwrap_or(Json::Null) -} -/// This binary's engine (the strand header's `engine`) -pub fn engine() -> String { format!("probbit {}", env!("CARGO_PKG_VERSION")) } - -/// Validate the stored chain and lifecycle before extending it. This is deliberately not -/// inference replay or authentication: verify still recomputes every event. In particular, a -/// checkpoint cannot reactivate a retired individual or hide an invalid earlier control. -fn check_chain(text: &str) -> Result { - let ls = lines(text); - let err = |line: usize, why: String| perr("strand", format!("line {line}: {why}")); - let r = Replay::open(ls.first().copied().unwrap_or("")).map_err(|(i, why)| err(i, why))?; - let mut status = Status::Active; - let mut n = 0u64; - for i in 1..ls.len() { - let j = json::parse(ls[i]).map_err(|e| err(i + 1, e.msg))?; - if persona::canon(&j) != ls[i] { return Err(err(i + 1, "not canonical JSON".into())); } - if j.get("prev").and_then(Json::as_str) != Some(persona::digest_of(ls[i - 1]).as_str()) { - return Err(err(i + 1, "prev is not the sha256 of the line before".into())); - } - if let Some(k) = unknown_key(&j) { return Err(err(i + 1, unsupported(k))); } - match kind(&j) { - Kind::Control => { - let f = |k: &str| j.get(k).and_then(Json::as_str).unwrap_or(""); - status = status.after(f("control")).map_err(|e| err(i + 1, e.msg))?; - let line = control_line(f("prev"), f("control"), f("by"), f("reason"), f("at")).map_err(|e| err(i + 1, e.msg))?; - if line != ls[i] { return Err(err(i + 1, "the control line differs".into())); } - } - Kind::Event => { - if status != Status::Active { return Err(err(i + 1, format!("event while {}", status.name()))); } - n += 1; - if j.get("n").and_then(Json::as_f64) != Some(n as f64) { return Err(err(i + 1, format!("n is not {n}"))); } - let Some(Json::Obj(_)) = j.get("inputs") else { return Err(err(i + 1, "no input object".into())) }; - for k in ["state", "stance"] { - if !j.get(k).and_then(Json::as_str).is_some_and(|s| s.len() == 71 && s.starts_with("sha256:") && s[7..].bytes().all(|c| c.is_ascii_hexdigit())) { - return Err(err(i + 1, format!("invalid {k} digest"))); - } - } - } - Kind::Rest => { - if status != Status::Active { return Err(err(i + 1, format!("rest line while {}", status.name()))); } - if !ls.get(i + 1).and_then(|x| json::parse(x).ok()).is_some_and(|x| matches!(kind(&x), Kind::Event)) { return Err(err(i + 1, "a rest line must be followed by its event line".into())); } - } - Kind::Checkpoint => { - if status != Status::Active { return Err(err(i + 1, format!("checkpoint while {}", status.name()))); } - // Full state validation plus links to the immediately preceding event; no inference. - let cp = Replay::open_at(ls[0], ls[i - 1], ls[i], i + 1).map_err(|(at, why)| err(at, why))?; - if cp.live.n != n { return Err(err(i + 1, "checkpoint event count differs".into())); } - // Keep the header persona/state check independent of the caller's state. - debug_assert_eq!(cp.live.p.digest, r.live.p.digest); - } - } - } - Ok(status) -} - -/// Continue a strand: `text` is the strand so far and `st` the individual after its last line -> the individual, ready for the -/// next event (no header to write). The header's persona must be `p` (the same digest); the run uses the header's document, the -/// key order the strand replays with. The lines before are not replayed here (`verify` does that). -pub fn resume(text: &str, p: &Persona, st: State, clock: Clock) -> Result { - let bad = |m: String| perr("strand", m); - if !text.ends_with('\n') { return Err(bad("its last line is incomplete (no newline at the end)".into())); } - check_chain(text)?; - let lines = lines(text); - let h = json::parse(lines.first().copied().unwrap_or("")).map_err(|e| bad(format!("the header is not JSON: {}", e.msg)))?; - if h.get("probbit_strand").and_then(Json::as_f64) != Some(FORMAT) { return Err(bad("not a probbit strand (format 1)".into())); } - let q = persona::build(h.get("document").unwrap_or(&Json::Null)).map_err(|e| bad(format!("the header's persona: {}: {}", e.path, e.msg)))?; - if q.digest != p.digest { return Err(bad(format!("it is another persona's ({} != {})", &q.digest[..19], &p.digest[..19]))); } - let last = lines[lines.len() - 1]; - // the control lines after the last event or checkpoint line (the anchor; the header without either) set the status: an - // event or a checkpoint is only ever written while active - let (mut a, mut tail) = (lines.len() - 1, vec![]); - while a > 0 { let j = json::parse(lines[a]).map_err(|e| bad(format!("line {} is not JSON: {}", a + 1, e.msg)))?; - if !matches!(kind(&j), Kind::Control) { break; } tail.push(j); a -= 1; } - let (n, want) = if a == 0 { (0, h.get("state").and_then(|s| s.get("digest")).and_then(Json::as_str).unwrap_or("").to_string()) } else { - let j = json::parse(lines[a]).map_err(|e| bad(format!("line {} is not JSON: {}", a + 1, e.msg)))?; - match kind(&j) { Kind::Checkpoint => (j.get("checkpoint").and_then(Json::as_f64).unwrap_or(0.0) as u64, j.get("state").and_then(|s| s.get("digest")).and_then(Json::as_str).unwrap_or("").to_string()), - _ => (j.get("n").and_then(Json::as_f64).unwrap_or(0.0) as u64, j.get("state").and_then(Json::as_str).unwrap_or("").to_string()) } }; - if st.digest != want { return Err(perr("state", format!("not the strand's last state ({} != {})", &st.digest[..st.digest.len().min(19)], &want[..want.len().min(19)]))); } - // RFC 0009: the expectation's memory, folded from every event line's inputs and logged deviation - let expect = q.expect.as_ref().map(|_| { let mut m = persona::ExpectMem::new(); - for l in lines.iter().skip(1) { if let Ok(j) = json::parse(l) { if matches!(kind(&j), Kind::Event) { let d = j.get("deviation"); - let lp = d.filter(|d| d.get("cold").is_none()).and_then(|d| d.get("pred")).and_then(Json::as_f64); - persona::expect_step(&q, &mut m, j.get("inputs").unwrap_or(&Json::Null), lp); } } } m }); - let mut lv = Live { p: q, st, clock, last: 0.0, prev: persona::digest_of(last), n, status: Status::Active, controls: 0, checkpoints: 0, rests: 0, expect, traj: None }; - for c in tail.iter().rev() { let what = c.get("control").and_then(Json::as_str).unwrap_or(""); - lv.status = lv.status.after(what).unwrap_or(Status::Retired); lv.st = lv.st.zero_credit(&lv.p); lv.controls += 1; } - Ok(lv) -} /// A strand file to log to: a new file gets the header (returned, to write before the events); an existing strand is continued from `st`, /// which must be its last state pub fn open(path: &str, p: Persona, doc: &Json, st: State, clock: Clock) -> Result<(Live, Option), InErr> { @@ -848,40 +305,6 @@ fn draw(th: crate::theme::Theme, r: &Row, drawn: usize, name: &str, rest: &str, crate::tui::err_write(&s); b.len() } -/// The audit of a recorded life (`probbit persona bound PERSONA --audit STRAND`): the strand replayed line by line; after every -/// event and control line, per learned trait the resting TV between the individual and the same individual with its learned -/// weights at zero, as a share of its epsilon (the room used). An exceedance is impossible by the theorem; the audit shows it -/// on the recorded life. -pub fn bound_audit(text: &str, b: &persona::bound::Bound, eng: Engine) -> Result<(Json, bool), (usize, String)> { - let lines = lines(text); - let mut r = Replay::open(lines.first().copied().unwrap_or(""))?; - let p = r.live.p.clone(); let (traits, cap) = persona::bound::learned_traits(&p).ok_or((1, "the strand's persona has no learning block: nothing to audit".to_string()))?; - let rest = b.rest_hours.unwrap_or_else(|| persona::bound::default_rest(&p)); let seed = r.live.st.seed; - let e = |x: InErr| (0usize, format!("{}: {}", x.path, x.msg)); - let st0 = persona::init(&p, Some(seed), true, eng); let p0 = persona::bound::rest_odds(&p, &st0, rest, eng).map_err(e)?; - let mut eps = vec![]; let mut rr = vec![]; - for (k, t) in traits.iter().enumerate() { let sc = persona::bound::scope(&p, b, t, rest, Some(&st0)); - let bs = persona::bound::bases(&p, &sc, &st0).ok_or((0, format!("{t}: more than {} enumerated history combinations: the audit needs an exact epsilon", persona::bound::MAX_HISTORY)))?; - eps.push(persona::bound::search(&p, k, &sc.coords, cap, &bs, &p0[k], rest, eng).map_err(e)?.0); rr.push((sc.d_r / 4.0).tanh()); } - let nt = traits.len(); let (mut maxr, mut at_n, mut cur, mut exceed) = (vec![0.0f64; nt], vec![0u64; nt], vec![0.0f64; nt], 0usize); - let (mut events, mut feedback, mut erasures) = (0u64, 0u64, 0u64); let mut last = persona::bound::learned_of(&r.live.st); - let measure = |st: &persona::State| -> Result, (usize, String)> { let a = persona::bound::rest_odds(&p, st, rest, eng).map_err(e)?; - let z = persona::bound::rest_odds(&p, &persona::bound::with_learned(&p, st, &persona::bound::learned_of(st).iter().map(|d| vec![0.0; d.len()]).collect::>()), rest, eng).map_err(e)?; - Ok(a.iter().zip(&z).map(|(x, y)| persona::bound::tv(x, y)).collect()) }; - for l in lines.iter().skip(1) { if l.trim().is_empty() { continue; } - let was_event = r.step(l, eng)?.is_some(); if was_event { events += 1; } else if l.contains(r#""control":"erase""#) { erasures += 1; } - let now = persona::bound::learned_of(&r.live.st); if now != last { if was_event { feedback += 1; } last = now; } - let tvs = measure(&r.live.st)?; - for k in 0..nt { cur[k] = tvs[k]; if eps[k] > 0.0 && tvs[k] / eps[k] > maxr[k] { maxr[k] = tvs[k] / eps[k]; at_n[k] = r.live.n; } if tvs[k] > eps[k] + rr[k] + 1e-6 { exceed += 1; } } } - let s = |x: &str| Json::Str(x.to_string()); - let rows: Vec = traits.iter().enumerate().map(|(k, t)| Json::Obj(vec![("trait".into(), s(t)), ("epsilon".into(), Json::Num(eps[k])), - ("room_used_max".into(), Json::Num(persona::r6(maxr[k]))), ("at_event".into(), Json::Num(at_n[k] as f64)), ("room_used_now".into(), Json::Num(persona::r6(if eps[k] > 0.0 { cur[k] / eps[k] } else { 0.0 }))), - ("distance_to_ceiling".into(), Json::Num(persona::r6(eps[k] - cur[k]))), ("tv_now".into(), Json::Num(persona::r6(cur[k])))])).collect(); - let ok = exceed == 0; - Ok((Json::Obj(vec![("persona".into(), s(&p.name)), ("digest".into(), s(&p.digest)), ("seed".into(), Json::Num(seed as f64)), ("rest_hours".into(), Json::Num(rest)), - ("events".into(), Json::Num(events as f64)), ("feedback_events".into(), Json::Num(feedback as f64)), ("erasures".into(), Json::Num(erasures as f64)), - ("traits".into(), Json::Arr(rows)), ("exceedances".into(), Json::Num(exceed as f64)), ("ok".into(), Json::Bool(ok))]), ok)) -} #[cfg(test)] mod tests { diff --git a/probbit-cli/src/main.rs b/probbit-cli/src/main.rs index 7bee82a..d8a798e 100644 --- a/probbit-cli/src/main.rs +++ b/probbit-cli/src/main.rs @@ -17,23 +17,18 @@ //! 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 //! --top, --summary (decide, run), --live (demo), --plain: see `probbit --help`; visuals go to stderr (theme.rs) -mod evaluate; mod fuzz; -mod json; mod labels; mod live; mod mcp; mod monitor; -mod persona; -mod router; -mod run; -mod sys; mod theme; mod tui; mod usage; -mod yaml; +// the document layer and the strand writer: the probbit-persona library (the CLI's modules name them as `crate::json` etc.) +use probbit_persona::{evaluate, json, persona, router, run, sys}; use json::{num, obj, str as jstr, Json}; -pub(crate) use router::{demo_doc, from_json, item_bars, DEMO_TPL}; +pub(crate) use router::{demo_doc, from_json, DEMO_TPL}; use probbit_decide::*; use std::io::{Read, Write}; use usage::{Command, Flag, Sub}; @@ -99,10 +94,6 @@ fn exact_ms(args: &[String]) -> Option { args.iter().any(|a| a == "--exact-ms").then(|| { let x: f64 = arg(args, "--exact-ms", 0.0); if !(x.is_finite() && (0.0..=1e9).contains(&x)) { fail("--exact-ms must be a number of milliseconds from 0 to 1e9") } x }) } -/// Telemetry fields of a sampled answer when --exact-ms was given (none otherwise): the cap and whether the exact tiers hit it. -pub(crate) fn exact_cap(xms: Option, reached: bool) -> Vec<(&'static str, Json)> { - xms.map_or(vec![], |x| vec![("exact_ms", num(x)), ("exact_ms_reached", Json::Bool(reached))]) -} /// Every stdout document goes through here. `println!` panicked on a closed pipe (`probbit demo --tasks 3000 | head -c 20`: /// "failed printing to stdout: Broken pipe", exit 134 with panic = abort); a reader that went away is not an error, so a broken /// pipe is ignored and the command keeps its normal exit code. @@ -1150,6 +1141,8 @@ fn help_cmd(args: &[String]) { } fn main() { + // the library's hooks: the `--top` monitor and the config echo in telemetry + probbit_persona::hooks::set_monitor(tui::tier, tui::gate_seen); probbit_persona::hooks::set_config_echo(config_echo); // an argument that is not valid Unicode (a file name in a legacy encoding on Linux, an unpaired surrogate on Windows) is // refused by name; std::env::args would panic on it let args: Vec = std::env::args_os().skip(1).enumerate().map(|(i, a)| a.into_string().unwrap_or_else(|a| diff --git a/probbit-cli/tests/cli.rs b/probbit-cli/tests/cli.rs index 42a66ed..6fb0d1b 100644 --- a/probbit-cli/tests/cli.rs +++ b/probbit-cli/tests/cli.rs @@ -3,7 +3,7 @@ use std::io::Write; use std::process::{Command, Stdio}; // The crate's own zero-dependency JSON reader: error documents must re-parse with it (P0.1 / P0.2 contract tests) #[allow(dead_code)] -#[path = "../src/json.rs"] +#[path = "../../probbit-persona/src/json.rs"] mod json; fn probbit(args: &[&str], stdin: &str) -> (i32, String, String) { diff --git a/probbit-cli/tests/cli_check.rs b/probbit-cli/tests/cli_check.rs index 22fc683..b8a0e83 100644 --- a/probbit-cli/tests/cli_check.rs +++ b/probbit-cli/tests/cli_check.rs @@ -3,7 +3,7 @@ use std::io::Write; use std::process::{Command, Stdio}; -#[path = "../src/json.rs"] +#[path = "../../probbit-persona/src/json.rs"] #[allow(dead_code)] mod json; use json::Json; diff --git a/probbit-cli/tests/cli_output.rs b/probbit-cli/tests/cli_output.rs index a7fceea..24fb1b0 100644 --- a/probbit-cli/tests/cli_output.rs +++ b/probbit-cli/tests/cli_output.rs @@ -1,7 +1,7 @@ //! Human formatting must not change machine values, persisted state or replay records. use std::process::{Command, Output}; #[allow(dead_code)] -#[path = "../src/json.rs"] +#[path = "../../probbit-persona/src/json.rs"] mod json; const PERSONA: &str = "../examples/persona/tutor.yaml"; diff --git a/probbit-cli/tests/evaluate.rs b/probbit-cli/tests/evaluate.rs index 9c981e4..3a26ed0 100644 --- a/probbit-cli/tests/evaluate.rs +++ b/probbit-cli/tests/evaluate.rs @@ -6,7 +6,7 @@ use std::io::Write; use std::process::{Command, Stdio}; #[allow(dead_code)] -#[path = "../src/json.rs"] +#[path = "../../probbit-persona/src/json.rs"] mod json; use json::{num, obj, str as jstr, Json}; diff --git a/probbit-cli/tests/live.rs b/probbit-cli/tests/live.rs index 5842dcf..49bd10b 100644 --- a/probbit-cli/tests/live.rs +++ b/probbit-cli/tests/live.rs @@ -8,7 +8,7 @@ use std::io::Write; use std::process::{Command, Stdio}; #[allow(dead_code)] -#[path = "../src/json.rs"] +#[path = "../../probbit-persona/src/json.rs"] mod json; use json::Json; diff --git a/probbit-cli/tests/persona.rs b/probbit-cli/tests/persona.rs index f35da0a..e5cd467 100644 --- a/probbit-cli/tests/persona.rs +++ b/probbit-cli/tests/persona.rs @@ -4,10 +4,10 @@ use std::io::Write; use std::process::{Command, Stdio}; #[allow(dead_code)] -#[path = "../src/json.rs"] +#[path = "../../probbit-persona/src/json.rs"] mod json; #[allow(dead_code)] -#[path = "../src/yaml.rs"] +#[path = "../../probbit-persona/src/yaml.rs"] mod yaml; use json::Json; diff --git a/probbit-cli/tests/processor_contracts.rs b/probbit-cli/tests/processor_contracts.rs index 653841e..ed32c67 100644 --- a/probbit-cli/tests/processor_contracts.rs +++ b/probbit-cli/tests/processor_contracts.rs @@ -1,7 +1,7 @@ //! Public processor responses: released assignments are never confused with the //! legacy full diagnostic candidate. Fixed-work behavior is thread invariant. #[allow(dead_code)] -#[path = "../src/json.rs"] +#[path = "../../probbit-persona/src/json.rs"] mod json; use json::Json; use std::io::Write; diff --git a/probbit-cli/tests/processor_json.rs b/probbit-cli/tests/processor_json.rs index 4833f85..4789514 100644 --- a/probbit-cli/tests/processor_json.rs +++ b/probbit-cli/tests/processor_json.rs @@ -1,5 +1,5 @@ #[allow(dead_code)] -#[path = "../src/json.rs"] +#[path = "../../probbit-persona/src/json.rs"] mod json; #[test] diff --git a/probbit-cli/tests/stress.rs b/probbit-cli/tests/stress.rs index 4a3fc56..8e38845 100644 --- a/probbit-cli/tests/stress.rs +++ b/probbit-cli/tests/stress.rs @@ -6,7 +6,7 @@ use std::process::{Command, Stdio}; use std::io::Write; #[allow(dead_code)] -#[path = "../src/json.rs"] +#[path = "../../probbit-persona/src/json.rs"] mod json; use json::Json; diff --git a/probbit-core/Cargo.toml b/probbit-core/Cargo.toml index 1feb7b5..b1f3496 100644 --- a/probbit-core/Cargo.toml +++ b/probbit-core/Cargo.toml @@ -10,4 +10,11 @@ readme = "../README.md" keywords = ["p-bit", "ising", "monte-carlo", "philox", "probabilistic"] categories = ["algorithms", "science", "simulation"] +[features] +default = ["std"] +std = [] +# no_std + alloc (a target without an operating system): the std subset comes from probbit-nostd +nostd = ["dep:probbit-nostd"] + [dependencies] +probbit-nostd = { path = "../probbit-nostd", version = "0.13.0", optional = true } diff --git a/probbit-core/src/lib.rs b/probbit-core/src/lib.rs index 58c8ef8..dcd080a 100644 --- a/probbit-core/src/lib.rs +++ b/probbit-core/src/lib.rs @@ -5,6 +5,17 @@ //! `sweep_prefix` is the PT-in-a-word variant (per-lane betas, shared uniform -> contiguous lane prefix mask). //! - `heatbath_f32`: T1 SoA f32 heat-bath on a 2D ±J lattice with LUT sigmoid. //! - `rt`: the engine's clock and its no-thread switch (for targets without threads or a std clock: wasm32-unknown-unknown). +#![cfg_attr(not(feature = "std"), no_std)] +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +#[macro_use] +extern crate probbit_nostd as std; +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +#[macro_use] +extern crate alloc; +#[allow(unused_imports)] +use std::prelude::rust_2021::*; #[derive(Clone)] pub struct Philox4x32 { key: [u32; 2], ctr: [u32; 4], buf: [u32; 4], idx: usize } @@ -63,6 +74,8 @@ impl Rng32 for Philox4x32 { #[inline(always)] fn u32(&mut self) -> u32 { self.ne /// 2D ±J periodic lattice, bit-sliced: w[i] holds spin i of 64 replicas (bit=1 means -1). pub mod multispin { use super::Rng32; + #[allow(unused_imports)] + use std::prelude::rust_2021::*; /// The fields are private: `sweep_fast` reads through raw pointers and relies on `w`, `jr` and `jd` holding exactly `l * l` /// words. With `pub` fields safe code could break that (`l = 2^32` on empty vectors: `l * l` wrapped to 0 in release, the /// length check passed, the sweep read through a dangling pointer). Build one with `random` or `new`. @@ -170,13 +183,15 @@ pub mod multispin { } } pub fn thresholds(beta: f64) -> (u32, u32) { - ((((-4.0 * beta).exp()) * 4294967295.0) as u32, (((-8.0 * beta).exp()) * 4294967295.0) as u32) + (((super::exp64(-4.0 * beta)) * 4294967295.0) as u32, ((super::exp64(-8.0 * beta)) * 4294967295.0) as u32) } } /// T1: SoA f32 heat-bath on 2D ±J lattice, LUT for tanh(beta*h), h in {-4,-2,0,2,4}. pub mod heatbath_f32 { use super::Rng32; + #[allow(unused_imports)] + use std::prelude::rust_2021::*; /// The fields are private: besides the `multispin` lengths, `sweep_fast` reads its 9-entry table at h + 4 unchecked, which /// is in range only for ±1 spins and couplings (`pub` couplings of 1000.0 read past the table). `random` and `new` build /// only such lattices, and the sweeps write only ±1. @@ -241,9 +256,15 @@ pub mod heatbath_f32 { }} } } - pub fn lut(beta: f32) -> [f32; 9] { let mut t = [0f32; 9]; for k in 0..9 { t[k] = (beta * (k as f32 - 4.0)).tanh(); } t } + pub fn lut(beta: f32) -> [f32; 9] { let mut t = [0f32; 9]; for k in 0..9 { t[k] = super::tanh32(beta * (k as f32 - 4.0)); } t } } +/// `exp` and `tanh` of the kernels: std's (the platform libm) with std, the `libm` crate's on no_std +#[cfg(feature = "std")] fn exp64(x: f64) -> f64 { x.exp() } +#[cfg(feature = "std")] fn tanh32(x: f32) -> f32 { x.tanh() } +#[cfg(not(feature = "std"))] fn exp64(x: f64) -> f64 { std::FloatExt::exp(x) } +#[cfg(not(feature = "std"))] fn tanh32(x: f32) -> f32 { std::FloatExt::tanh(x) } + /// The runtime probbit-ir and probbit-decide run on: their clock and whether they may spawn threads. /// /// `Instant` is `std::time::Instant` on every target that has one (the CLI: unchanged behaviour, the same type). On @@ -257,17 +278,17 @@ pub mod heatbath_f32 { /// threads sized for > 2,048-variable programs are not used. pub mod rt { use std::cell::Cell; - thread_local! { static SEQUENTIAL: Cell = const { Cell::new(cfg!(all(target_family = "wasm", target_os = "unknown"))) }; } + thread_local! { static SEQUENTIAL: Cell = const { Cell::new(cfg!(any(not(feature = "std"), all(target_family = "wasm", target_os = "unknown")))) }; } /// No threads on this thread's calls into the engine (see the module doc). Returns the previous setting. pub fn set_sequential(on: bool) -> bool { SEQUENTIAL.with(|s| s.replace(on)) } /// Whether this thread's engine calls run without threads. pub fn sequential() -> bool { SEQUENTIAL.with(|s| s.get()) } - #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] + #[cfg(all(feature = "std", not(all(target_family = "wasm", target_os = "unknown"))))] pub use std::time::Instant; - #[cfg(all(target_family = "wasm", target_os = "unknown"))] + #[cfg(any(not(feature = "std"), all(target_family = "wasm", target_os = "unknown")))] pub use clock::{set_clock, Instant}; - #[cfg(all(target_family = "wasm", target_os = "unknown"))] + #[cfg(any(not(feature = "std"), all(target_family = "wasm", target_os = "unknown")))] mod clock { use std::cell::Cell; use std::time::Duration; diff --git a/probbit-decide/Cargo.toml b/probbit-decide/Cargo.toml index 2a1aeba..9989b3c 100644 --- a/probbit-decide/Cargo.toml +++ b/probbit-decide/Cargo.toml @@ -10,6 +10,13 @@ readme = "../README.md" keywords = ["p-bit", "assignment", "routing", "constraints", "probabilistic"] categories = ["algorithms", "science"] +[features] +default = ["std"] +std = ["probbit-core/std", "probbit-ir/std"] +# no_std + alloc (a target without an operating system): the std subset comes from probbit-nostd +nostd = ["dep:probbit-nostd", "probbit-core/nostd", "probbit-ir/nostd"] + [dependencies] -probbit-core = { path = "../probbit-core", version = "0.13.0" } -probbit-ir = { path = "../probbit-ir", version = "0.13.0" } +probbit-nostd = { path = "../probbit-nostd", version = "0.13.0", optional = true } +probbit-core = { path = "../probbit-core", default-features = false, version = "0.13.0" } +probbit-ir = { path = "../probbit-ir", default-features = false, version = "0.13.0" } diff --git a/probbit-decide/src/ir.rs b/probbit-decide/src/ir.rs index 310db47..04b4cc2 100644 --- a/probbit-decide/src/ir.rs +++ b/probbit-decide/src/ir.rs @@ -1,6 +1,8 @@ //! probbit-ir v0: a portable, line-based text form of a constrained Potts decision problem, plus a lowering to a //! binary one-hot QUBO/Ising "p-bit topology" (what a hardware p-bit fabric natively samples). //! Floats are written as IEEE-754 bit patterns (hex) so the round trip is bit-exact. +#[allow(unused_imports)] +use std::prelude::rust_2021::*; use crate::Problem; pub fn to_ir(p: &Problem) -> String { diff --git a/probbit-decide/src/lib.rs b/probbit-decide/src/lib.rs index 088c65f..c490c7d 100644 --- a/probbit-decide/src/lib.rs +++ b/probbit-decide/src/lib.rs @@ -6,6 +6,17 @@ //! Router: exact enumeration when the feasible set is small, else constraint-preserving //! Gibbs (site heat-bath restricted to feasible values + Metropolis swap moves) with //! multi-chain split-R-hat gate -> "UNMIXED: escalate" refusal. +#![cfg_attr(not(feature = "std"), no_std)] +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +#[macro_use] +extern crate probbit_nostd as std; +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +#[macro_use] +extern crate alloc; +#[allow(unused_imports)] +use std::prelude::rust_2021::*; use probbit_core::Philox4x32; use std::collections::HashMap; pub mod oracle; diff --git a/probbit-decide/src/oracle.rs b/probbit-decide/src/oracle.rs index cb1390c..6239d6d 100644 --- a/probbit-decide/src/oracle.rs +++ b/probbit-decide/src/oracle.rs @@ -2,6 +2,8 @@ //! Chain-of-blocks Dispatch: B blocks x M=10 tasks. Block b may use 2 private agents plus the "bridge" //! agents shared with blocks b-1 and b+1 (capacity couples neighbours). Groups of 5 within a block, affinity lam. //! Exact marginals + bridge-load distributions by transfer-matrix DP over bridge loads. +#[allow(unused_imports)] +use std::prelude::rust_2021::*; use crate::Problem; use probbit_core::Philox4x32; diff --git a/probbit-ir/Cargo.toml b/probbit-ir/Cargo.toml index 50588f8..fbb6728 100644 --- a/probbit-ir/Cargo.toml +++ b/probbit-ir/Cargo.toml @@ -10,5 +10,12 @@ readme = "../README.md" keywords = ["p-bit", "constraints", "gibbs-sampling", "probabilistic", "ising"] categories = ["algorithms", "science", "mathematics"] +[features] +default = ["std"] +std = ["probbit-core/std"] +# no_std + alloc (a target without an operating system): the std subset comes from probbit-nostd +nostd = ["dep:probbit-nostd", "probbit-core/nostd"] + [dependencies] -probbit-core = { path = "../probbit-core", version = "0.13.0" } +probbit-nostd = { path = "../probbit-nostd", version = "0.13.0", optional = true } +probbit-core = { path = "../probbit-core", default-features = false, version = "0.13.0" } diff --git a/probbit-ir/src/lib.rs b/probbit-ir/src/lib.rs index 6b340d2..040aebc 100644 --- a/probbit-ir/src/lib.rs +++ b/probbit-ir/src/lib.rs @@ -8,6 +8,17 @@ //! updates restricted to feasible values + Metropolis swap moves, multi-chain), `gate_stats` (the certification gate //! over generic marginals), `Model::with_clamp` (what-if). Front-ends lower to this form: the assignment router in //! probbit-decide is one (`Problem::lower`), and its results through the lowering are bit-identical to its own engine. +#![cfg_attr(not(feature = "std"), no_std)] +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +#[macro_use] +extern crate probbit_nostd as std; +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +#[macro_use] +extern crate alloc; +#[allow(unused_imports)] +use std::prelude::rust_2021::*; use probbit_core::Philox4x32; use std::collections::HashMap; diff --git a/probbit-nostd/Cargo.toml b/probbit-nostd/Cargo.toml new file mode 100644 index 0000000..c903dbe --- /dev/null +++ b/probbit-nostd/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "probbit-nostd" +description = "The subset of std that probbit's crates use, from core + alloc, for targets without an operating system (no_std builds only)" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +readme = "../README.md" +keywords = ["p-bit", "no-std", "embedded", "probabilistic"] +categories = ["no-std", "embedded"] + +[lib] +test = false +doctest = false + +[dependencies] +hashbrown = { version = "0.15", default-features = false, features = ["default-hasher", "inline-more"] } +libm = "0.2" +portable-atomic = { version = "1", default-features = false, features = ["fallback", "critical-section"] } diff --git a/probbit-nostd/src/lib.rs b/probbit-nostd/src/lib.rs new file mode 100644 index 0000000..92e9ddc --- /dev/null +++ b/probbit-nostd/src/lib.rs @@ -0,0 +1,164 @@ +//! probbit-nostd: the subset of `std` that probbit-core, probbit-ir, probbit-decide and probbit-persona use, built from `core`, +//! `alloc`, `hashbrown`, `libm` and `portable-atomic` for a target without an operating system (xtensa-esp32s3-none-elf). +//! A crate that is `no_std` imports it as `std` (`#[macro_use] extern crate probbit_nostd as std;`), so its `std::` paths compile +//! unchanged. Threads run in order on the calling thread (the engine's `rt::sequential()` mode); `thread_local!` is a static +//! (one core runs the engine); files and sleeping are absent (`fs::read` fails, `sleep` returns at once). +#![no_std] +extern crate alloc; + +#[allow(deprecated)] // the integer modules (`std::u64::MAX` style paths) are deprecated in std too, and still exist there +pub use core::{any, array, ascii, cell, char, clone, cmp, convert, default, f32, f64, hash, hint, i16, i32, i64, i8, + isize, iter, marker, mem, num, ops, option, ptr, result, slice, str, u16, u32, u64, u8, usize}; +pub use alloc::{borrow, boxed, fmt, rc, string, vec}; + +pub mod time { pub use core::time::Duration; } + +pub mod collections { + pub use alloc::collections::*; + pub use hashbrown::{HashMap, HashSet}; + pub mod hash_map { pub use hashbrown::hash_map::*; } + pub mod hash_set { pub use hashbrown::hash_set::*; } +} + +pub mod sync { + pub use alloc::sync::*; + pub mod atomic { + pub use core::sync::atomic::{compiler_fence, fence, AtomicBool, AtomicI32, AtomicIsize, AtomicU32, AtomicUsize, Ordering}; + pub use portable_atomic::{AtomicI64, AtomicU64}; + } +} + +pub mod io { + /// The only I/O error here: there is no operating system. + #[derive(Debug, Clone, Copy)] + pub struct Error; + impl core::fmt::Display for Error { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { f.write_str("no operating system on this target") } } + pub type Result = core::result::Result; + static PRINT: core::sync::atomic::AtomicUsize = core::sync::atomic::AtomicUsize::new(0); + /// Install the console writer used by `print!`/`println!`/`eprintln!` (the firmware passes esp-println's). + pub fn set_print(f: fn(&str)) { PRINT.store(f as usize, core::sync::atomic::Ordering::Relaxed) } + #[doc(hidden)] + pub fn _print(args: core::fmt::Arguments<'_>) { + let p = PRINT.load(core::sync::atomic::Ordering::Relaxed); + if p != 0 { let f: fn(&str) = unsafe { core::mem::transmute(p) }; f(&alloc::fmt::format(args)); } + } +} + +pub mod fs { + /// No filesystem on bare metal: the firmware passes documents in as strings. + pub fn read

(_path: P) -> crate::io::Result> { Err(crate::io::Error) } +} + +pub mod panic { + pub fn resume_unwind(_payload: alloc::boxed::Box) -> ! { panic!("a sequential section panicked") } +} + +pub mod thread { + use core::cell::UnsafeCell; + use core::marker::PhantomData; + use core::time::Duration; + /// `std::thread::scope` without threads: every spawn runs at once on the calling thread. + pub struct Scope<'scope, 'env: 'scope> { _p: PhantomData<(&'scope mut &'scope (), &'env mut &'env ())> } + pub fn scope<'env, F, T>(f: F) -> T where F: for<'scope> FnOnce(&'scope Scope<'scope, 'env>) -> T { + let s = Scope { _p: PhantomData }; f(&s) + } + impl<'scope, 'env> Scope<'scope, 'env> { + pub fn spawn(&'scope self, f: F) -> ScopedJoinHandle<'scope, T> where F: FnOnce() -> T + Send + 'scope, T: Send + 'scope { + ScopedJoinHandle { v: f(), _p: PhantomData } + } + } + pub struct ScopedJoinHandle<'scope, T> { v: T, _p: PhantomData<&'scope ()> } + impl<'scope, T> ScopedJoinHandle<'scope, T> { + pub fn join(self) -> core::result::Result> { Ok(self.v) } + } + pub struct Builder; + impl Builder { + pub fn new() -> Builder { Builder } + pub fn stack_size(self, _size: usize) -> Builder { self } + pub fn name(self, _name: alloc::string::String) -> Builder { self } + pub fn spawn_scoped<'scope, 'env, F, T>(self, s: &'scope Scope<'scope, 'env>, f: F) -> crate::io::Result> + where F: FnOnce() -> T + Send + 'scope, T: Send + 'scope { Ok(s.spawn(f)) } + } + pub fn available_parallelism() -> crate::io::Result { Ok(core::num::NonZeroUsize::MIN) } + pub fn sleep(_d: Duration) {} + /// `thread_local!` storage: one static, initialised on first use (the engine runs on one core). + pub struct LocalKey { init: fn() -> T, slot: UnsafeCell> } + unsafe impl Sync for LocalKey {} + impl LocalKey { + #[doc(hidden)] + pub const fn new(init: fn() -> T) -> Self { LocalKey { init, slot: UnsafeCell::new(None) } } + pub fn with R, R>(&'static self, f: F) -> R { + let slot = unsafe { &mut *self.slot.get() }; + if slot.is_none() { *slot = Some((self.init)()); } + f(slot.as_ref().unwrap()) + } + } + impl LocalKey> { + pub fn get(&'static self) -> T { self.with(|c| c.get()) } + pub fn set(&'static self, v: T) { self.with(|c| c.set(v)) } + pub fn replace(&'static self, v: T) -> T { self.with(|c| c.replace(v)) } + } +} + +#[macro_export] +macro_rules! thread_local { + () => {}; + ($(#[$a:meta])* $vis:vis static $name:ident : $t:ty = const { $init:expr } $(; $($rest:tt)*)?) => { + $(#[$a])* $vis static $name: $crate::thread::LocalKey<$t> = $crate::thread::LocalKey::new({ fn __init() -> $t { $init } __init }); + $($crate::thread_local!($($rest)*);)? + }; + ($(#[$a:meta])* $vis:vis static $name:ident : $t:ty = $init:expr $(; $($rest:tt)*)?) => { + $(#[$a])* $vis static $name: $crate::thread::LocalKey<$t> = $crate::thread::LocalKey::new({ fn __init() -> $t { $init } __init }); + $($crate::thread_local!($($rest)*);)? + }; +} +#[macro_export] +macro_rules! print { ($($t:tt)*) => { $crate::io::_print(format_args!($($t)*)) }; } +#[macro_export] +macro_rules! println { () => { $crate::io::_print(format_args!("\n")) }; ($($t:tt)*) => { $crate::io::_print(format_args!("{}\n", format_args!($($t)*))) }; } +#[macro_export] +macro_rules! eprint { ($($t:tt)*) => { $crate::io::_print(format_args!($($t)*)) }; } +#[macro_export] +macro_rules! eprintln { () => { $crate::io::_print(format_args!("\n")) }; ($($t:tt)*) => { $crate::io::_print(format_args!("{}\n", format_args!($($t)*))) }; } + +/// `f64`/`f32` methods that live in `std` (they call the platform libm) and not in `core`: here they call the `libm` crate. +/// Inherent methods win where `core` has them, so this trait only fills the gaps. +pub trait FloatExt: Sized { + fn exp(self) -> Self; fn exp_m1(self) -> Self; fn ln(self) -> Self; fn ln_1p(self) -> Self; fn log2(self) -> Self; fn log10(self) -> Self; + fn sqrt(self) -> Self; fn powf(self, e: Self) -> Self; fn powi(self, n: i32) -> Self; fn tanh(self) -> Self; fn cos(self) -> Self; fn sin(self) -> Self; + fn floor(self) -> Self; fn ceil(self) -> Self; fn round(self) -> Self; fn trunc(self) -> Self; fn fract(self) -> Self; +} +/// `powi` as compiler-rt's `__powidf2` computes it (what `std`'s `f64::powi` lowers to), not `pow`: same bits as the host. +fn powi64(mut a: f64, mut b: i32) -> f64 { + let recip = b < 0; let mut r = 1.0; + loop { if b & 1 != 0 { r *= a; } b /= 2; if b == 0 { break; } a *= a; } + if recip { 1.0 / r } else { r } +} +impl FloatExt for f64 { + fn exp(self) -> f64 { libm::exp(self) } fn exp_m1(self) -> f64 { libm::expm1(self) } fn ln(self) -> f64 { libm::log(self) } + fn ln_1p(self) -> f64 { libm::log1p(self) } fn log2(self) -> f64 { libm::log2(self) } fn log10(self) -> f64 { libm::log10(self) } + fn sqrt(self) -> f64 { libm::sqrt(self) } fn powf(self, e: f64) -> f64 { libm::pow(self, e) } fn powi(self, n: i32) -> f64 { powi64(self, n) } + fn tanh(self) -> f64 { libm::tanh(self) } fn cos(self) -> f64 { libm::cos(self) } fn sin(self) -> f64 { libm::sin(self) } + fn floor(self) -> f64 { libm::floor(self) } fn ceil(self) -> f64 { libm::ceil(self) } fn round(self) -> f64 { libm::round(self) } + fn trunc(self) -> f64 { libm::trunc(self) } fn fract(self) -> f64 { self - libm::trunc(self) } +} +impl FloatExt for f32 { + fn exp(self) -> f32 { libm::expf(self) } fn exp_m1(self) -> f32 { libm::expm1f(self) } fn ln(self) -> f32 { libm::logf(self) } + fn ln_1p(self) -> f32 { libm::log1pf(self) } fn log2(self) -> f32 { libm::log2f(self) } fn log10(self) -> f32 { libm::log10f(self) } + fn sqrt(self) -> f32 { libm::sqrtf(self) } fn powf(self, e: f32) -> f32 { libm::powf(self, e) } fn powi(self, n: i32) -> f32 { powi64(self as f64, n) as f32 } + fn tanh(self) -> f32 { libm::tanhf(self) } fn cos(self) -> f32 { libm::cosf(self) } fn sin(self) -> f32 { libm::sinf(self) } + fn floor(self) -> f32 { libm::floorf(self) } fn ceil(self) -> f32 { libm::ceilf(self) } fn round(self) -> f32 { libm::roundf(self) } + fn trunc(self) -> f32 { libm::truncf(self) } fn fract(self) -> f32 { self - libm::truncf(self) } +} + +/// What a `no_std` module adds with one line (`use std::prelude::rust_2021::*;`) to see the names `std`'s prelude gives it. +pub mod prelude { + pub mod rust_2021 { + pub use core::prelude::rust_2021::*; + pub use alloc::borrow::ToOwned; + pub use alloc::boxed::Box; + pub use alloc::string::{String, ToString}; + pub use alloc::vec::Vec; + pub use crate::FloatExt; + } +} diff --git a/probbit-persona/Cargo.toml b/probbit-persona/Cargo.toml new file mode 100644 index 0000000..84dd2b8 --- /dev/null +++ b/probbit-persona/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "probbit-persona" +description = "probbit's document layer as a library: JSON/YAML documents, the persona layer, run/decide/evaluate, and the strand writer and verifier; std or no_std + alloc" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +readme = "../README.md" +keywords = ["p-bit", "persona", "no-std", "decision", "probabilistic"] +categories = ["algorithms", "no-std", "science"] + +[features] +default = ["std", "decide"] +std = ["probbit-core/std", "probbit-ir/std", "probbit-decide?/std"] +# no_std + alloc (a target without an operating system): the std subset comes from probbit-nostd; documents come in as strings +nostd = ["dep:probbit-nostd", "probbit-core/nostd", "probbit-ir/nostd", "probbit-decide?/nostd"] +# `router` (probbit decide) and `evaluate` (the decision-API adapter): probbit-decide +decide = ["dep:probbit-decide"] + +[dependencies] +probbit-nostd = { path = "../probbit-nostd", version = "0.13.0", optional = true } +probbit-core = { path = "../probbit-core", default-features = false, version = "0.13.0" } +probbit-ir = { path = "../probbit-ir", default-features = false, version = "0.13.0" } +probbit-decide = { path = "../probbit-decide", default-features = false, version = "0.13.0", optional = true } diff --git a/probbit-cli/src/bound.rs b/probbit-persona/src/bound.rs similarity index 100% rename from probbit-cli/src/bound.rs rename to probbit-persona/src/bound.rs diff --git a/probbit-cli/src/evaluate.rs b/probbit-persona/src/evaluate.rs similarity index 98% rename from probbit-cli/src/evaluate.rs rename to probbit-persona/src/evaluate.rs index 908374f..04b294d 100644 --- a/probbit-cli/src/evaluate.rs +++ b/probbit-persona/src/evaluate.rs @@ -13,6 +13,8 @@ //! copied as they are. The program runs exactly as `probbit run` runs it (`--program` prints it). //! Response = `model`, `answers` (keyed by question id, each in the judge's answer shape plus a `probbit` object), `usage`, then every //! field of the `probbit run` document. Docs: docs/probbit-ir-json.md "Decision API". +#[allow(unused_imports)] +use std::prelude::rust_2021::*; use crate::json::{arr, at, fields, keyed, num, number, obj, opt, req, schema, str as jstr, text, value, weight, InErr, Json}; use std::collections::{HashMap, HashSet}; @@ -105,6 +107,7 @@ pub fn compile(j: &Json) -> Result { let mut w = vec![0.0; q.options.len()]; for (o, v) in keyed(x, &p)? { let k = q.options.iter().position(|y| y == o).ok_or_else(|| value(&at(&p, o), format!("question {id}: unknown option {o}")))?; w[k] = weight(v, &at(&p, o))?; } set(i, w, "probbit.logw", &p)?; } } + drop(qidx); // no_std: hashbrown's map drop is not `may_dangle`, so the borrow of `qs` ends here, not at the scope end (as run.rs) for (q, w) in qs.iter_mut().zip(&h) { q.w = w.clone(); } // the alphabet: score levels first in order (precedes / linear read values as ordered slots), then "false", "true", then // every choice option in order of first appearance; each variable allows its own options only diff --git a/probbit-persona/src/hooks.rs b/probbit-persona/src/hooks.rs new file mode 100644 index 0000000..5312e21 --- /dev/null +++ b/probbit-persona/src/hooks.rs @@ -0,0 +1,34 @@ +//! The host's hooks into the decision pipeline: the CLI's `--top` monitor (the tier that is running, what the gate saw) and its +//! configuration echo (`telemetry.config`, the probbit.json in effect). Unset, the monitor calls do nothing and the echo is empty, +//! which is what probbit-wasm and a firmware want. The CLI installs its own at start (probbit-cli/src/main.rs). +#[allow(unused_imports)] +use std::prelude::rust_2021::*; +use crate::json::Json; +use std::sync::atomic::{AtomicUsize, Ordering}; + +// fn pointers as usize: an atomic store works on every target the engine builds for, with or without std +static TIER: AtomicUsize = AtomicUsize::new(0); +static GATE_SEEN: AtomicUsize = AtomicUsize::new(0); +static CONFIG_ECHO: AtomicUsize = AtomicUsize::new(0); + +/// Install the monitor: `tier` gets the name of each tier as it starts, `gate_seen` the gate's R-hat, its TV bound, the released +/// variables and all of them. +pub fn set_monitor(tier: fn(&'static str), gate_seen: fn(f64, f64, usize, usize)) { + TIER.store(tier as usize, Ordering::Relaxed); GATE_SEEN.store(gate_seen as usize, Ordering::Relaxed); +} +/// Install the configuration echo: the telemetry fields that name the configuration in effect. +pub fn set_config_echo(f: fn() -> Vec<(&'static str, Json)>) { CONFIG_ECHO.store(f as usize, Ordering::Relaxed) } + +pub fn tier(t: &'static str) { + let p = TIER.load(Ordering::Relaxed); + if p != 0 { let f: fn(&'static str) = unsafe { core::mem::transmute(p) }; f(t) } +} +pub fn gate_seen(rhat: f64, tv: f64, released: usize, n: usize) { + let p = GATE_SEEN.load(Ordering::Relaxed); + if p != 0 { let f: fn(f64, f64, usize, usize) = unsafe { core::mem::transmute(p) }; f(rhat, tv, released, n) } +} +pub fn config_echo() -> Vec<(&'static str, Json)> { + let p = CONFIG_ECHO.load(Ordering::Relaxed); + if p == 0 { return vec![]; } + let f: fn() -> Vec<(&'static str, Json)> = unsafe { core::mem::transmute(p) }; f() +} diff --git a/probbit-cli/src/hostlint.rs b/probbit-persona/src/hostlint.rs similarity index 100% rename from probbit-cli/src/hostlint.rs rename to probbit-persona/src/hostlint.rs diff --git a/probbit-cli/src/json.rs b/probbit-persona/src/json.rs similarity index 99% rename from probbit-cli/src/json.rs rename to probbit-persona/src/json.rs index 6cebfaf..8f11cc1 100644 --- a/probbit-cli/src/json.rs +++ b/probbit-persona/src/json.rs @@ -1,4 +1,6 @@ //! Minimal JSON reader/writer (no external crates). Enough for the probbit problem/decision documents. +#[allow(unused_imports)] +use std::prelude::rust_2021::*; #[derive(Clone, Debug, PartialEq)] pub enum Json { Null, Bool(bool), Num(f64), Str(String), Arr(Vec), Obj(Vec<(String, Json)>) } diff --git a/probbit-persona/src/lib.rs b/probbit-persona/src/lib.rs new file mode 100644 index 0000000..45c0018 --- /dev/null +++ b/probbit-persona/src/lib.rs @@ -0,0 +1,35 @@ +//! probbit-persona: probbit's document layer and strand writer as a library, for the CLI, the browser module and a firmware. +//! +//! - `json`, `yaml`: the reader/writer of the JSON documents and the YAML subset of persona files (no external crates). +//! - `persona`: the individuality layer (docs/persona.md): parse, build, lint, prove, bound, hostlint, the turn, `display.glyphs`. +//! - `run`: `probbit run` on a probbit-ir JSON program; `router` (feature `decide`): `probbit decide` on a router document; +//! `evaluate` (feature `decide`): the decision-API adapter. +//! - `strand`: the strand writer (`Live`: start, event, control, checkpoint and rest lines, the `prev` chain) and its checks over +//! a `&str` (`Replay`, `verify*`, `read_actions`, `first_difference`), with no file I/O. +//! - `sys`: the process's resource usage (getrusage on 64-bit Unix; `None` elsewhere). `hooks`: the host's monitor and config echo. +//! +//! Features: `std` (default) builds on std; `nostd` builds `no_std` + `alloc` for a target without an operating system, with +//! the std subset from probbit-nostd (docs/embedding.md). `decide` (default) adds `router` and `evaluate` (probbit-decide). +#![cfg_attr(not(feature = "std"), no_std)] +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +#[macro_use] +extern crate probbit_nostd as std; +#[cfg(not(feature = "std"))] +#[allow(unused_imports)] +#[macro_use] +extern crate alloc; +#[cfg(not(any(feature = "std", feature = "nostd")))] +compile_error!("probbit-persona needs feature `std` (the default) or `nostd`"); + +#[cfg(feature = "decide")] +pub mod evaluate; +pub mod hooks; +pub mod json; +pub mod persona; +#[cfg(feature = "decide")] +pub mod router; +pub mod run; +pub mod strand; +pub mod sys; +pub mod yaml; diff --git a/probbit-cli/src/persona.rs b/probbit-persona/src/persona.rs similarity index 99% rename from probbit-cli/src/persona.rs rename to probbit-persona/src/persona.rs index 78431dc..7f22ffa 100644 --- a/probbit-cli/src/persona.rs +++ b/probbit-persona/src/persona.rs @@ -9,6 +9,8 @@ //! 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. +#[allow(unused_imports)] +use std::prelude::rust_2021::*; use crate::json::{self, at, ix, InErr, Json}; use probbit_core::rt::Instant; use std::collections::HashMap; @@ -100,7 +102,7 @@ pub fn sha(j: &Json) -> String { digest_of(&canon(j)) } /// 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 -pub(crate) fn r6(x: f64) -> f64 { let v = round_to(x, 6); if v == 0.0 { 0.0 } else { v } } +pub 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 @@ -292,7 +294,12 @@ fn effect(e: &Json, path: &str, levels: &[String]) -> R> { } } +/// A persona file: `.json` = JSON, anything else the YAML subset. -> (persona, the document). Without `std` there are no files: +/// an error (the document comes in as a string: `parse_doc` + `build`). +#[cfg(not(feature = "std"))] +pub fn load(path: &str) -> R<(Persona, Json)> { Err(perr(path, "cannot read the persona file: no file system on this target (pass the document as a string: parse_doc + build)")) } /// A persona file: `.json` = JSON, anything else the YAML subset. -> (persona, the document) +#[cfg(feature = "std")] 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())))?; diff --git a/probbit-cli/src/router.rs b/probbit-persona/src/router.rs similarity index 94% rename from probbit-cli/src/router.rs rename to probbit-persona/src/router.rs index 8223558..4764c6f 100644 --- a/probbit-cli/src/router.rs +++ b/probbit-persona/src/router.rs @@ -1,18 +1,23 @@ //! `probbit decide`, the router front-end: the router document -> `probbit_decide::Problem` (the strict input contract), and the decision //! pipeline (the exact tiers, then the gated sampler and the polish) as one function that returns the document and its exit code. //! main.rs parses the flags and prints it; probbit-wasm runs the same code in a browser. +#[allow(unused_imports)] +use std::prelude::rust_2021::*; use crate::json::{self, num, obj, str as jstr, Json}; -use crate::{config_echo, exact_cap, sys, tui}; +use crate::hooks::{self as tui, config_echo}; +use crate::run::exact_cap; +pub use crate::run::item_bars; +use crate::sys; use probbit_core::rt::Instant; use probbit_decide::*; const VERSION: &str = env!("CARGO_PKG_VERSION"); -pub(crate) struct Named { pub(crate) p: Problem, pub(crate) tasks: Vec, pub(crate) workers: Vec } +pub struct Named { pub p: Problem, pub tasks: Vec, pub workers: Vec } /// JSON problem -> Problem. See README "Problem format"; the strict contract (types, unknown fields, duplicates, empty domains, /// structured errors) is docs/probbit-ir-json.md "Input contract". -pub(crate) fn from_json(j: &Json) -> Result { +pub fn from_json(j: &Json) -> Result { use json::{arr, at, fields, ix, limit, opt, req, schema, text, value, weight}; fields(j, "", &["workers", "tasks", "affinity", "comment"])?; if let Some(c) = opt(j, "comment") { text(c, "comment")?; } @@ -92,13 +97,13 @@ fn components_tier(n: &Named, lowered: &probbit_ir::Model, exact_limit: u64, fr_ Some((obj(std::mem::take(out)), 0)) } /// The decision flags of `probbit decide` (main.rs reads them from the command line, probbit-wasm from a `flags` object). -pub(crate) struct Opts { pub(crate) budget: f64, pub(crate) seed: u64, pub(crate) exact_limit: u64, pub(crate) polish_ms: f64, pub(crate) polish_sweeps: usize, - pub(crate) fr_states: usize, pub(crate) sweeps: usize, pub(crate) mode: String, pub(crate) chains: usize, pub(crate) threads: usize, pub(crate) cpu_pct: u32, - pub(crate) xms: Option } +pub struct Opts { pub budget: f64, pub seed: u64, pub exact_limit: u64, pub polish_ms: f64, pub polish_sweeps: usize, + pub fr_states: usize, pub sweeps: usize, pub mode: String, pub chains: usize, pub threads: usize, pub cpu_pct: u32, + pub xms: Option } /// `probbit decide` on a parsed document: the exact tiers, then the gated sampler and the polish. -> (the decision document, its exit /// code: 0 answer, 1 infeasible, 3 refused / declined; the per-task error bars for `--summary`). `mem` = (--mem-limit-mb, rows per /// chain), read only when the sampler runs. -pub(crate) fn decide(n: &Named, o: &Opts, mem: &dyn Fn() -> (usize, usize)) -> (Json, i32, Vec) { +pub fn decide(n: &Named, o: &Opts, mem: &dyn Fn() -> (usize, usize)) -> (Json, i32, Vec) { let (mut doc, code, bars) = decide_inner(n, o, mem); crate::run::release_contract(&mut doc); (doc, code, bars) } @@ -180,11 +185,8 @@ fn decide_inner(n: &Named, o: &Opts, mem: &dyn Fn() -> (usize, usize)) -> (Json, ("ms", num(t0.elapsed().as_secs_f64() * 1e3))]); (obj(out), if verdict == "refused" { 3 } else { 0 }, item_bars(&g)) } -/// Per item, the error bar the gate compares with `tv_tol`: z x max(MCSE short, MCSE long) (`Gate::released_tasks`). -pub(crate) fn item_bars(g: &Gate) -> Vec { g.sig_tv.iter().zip(&g.sig_tv_long).map(|(&s, &l)| GATE.z * if s.is_nan() || l.is_nan() { f64::NAN } else { s.max(l) }).collect() } - /// The demo's task templates: (text, PII, production DB, judge fit per worker in `DEMO_W` order). -pub(crate) const DEMO_TPL: [(&str, bool, bool, [f64; 6]); 8] = [ +pub const DEMO_TPL: [(&str, bool, bool, [f64; 6]); 8] = [ ("draft reply: refund dispute (PII)", true, false, [2.0, 1.6, 1.0, 0.6, -1.0, 1.2]), ("summarize 40-page contract (PII)", true, false, [2.2, 1.8, 0.8, 0.9, -1.0, 0.5]), ("refactor auth middleware", false, false, [1.6, 1.2, 0.0, -0.5, 2.0, -1.0]), @@ -194,7 +196,7 @@ pub(crate) const DEMO_TPL: [(&str, bool, bool, [f64; 6]); 8] = [ ("fix flaky CI test", false, false, [1.0, 0.8, 0.2, -0.5, 1.8, -2.0]), ("investor update draft", false, false, [1.8, 1.4, 0.6, 0.2, -1.0, 0.8])]; /// The demo document for `n` tasks (seeded): the bytes of `probbit demo` since 0.1. -pub(crate) fn demo_doc(n: usize, seed: u64, hard: bool) -> Json { +pub fn demo_doc(n: usize, seed: u64, hard: bool) -> Json { const W: [(&str, usize); 6] = [("opus", 2), ("sonnet", 2), ("luna-pro", 3), ("local-gemma", 2), ("codex", 2), ("human", 3)]; const CUST: [&str; 8] = ["acme", "globex", "initech", "umbrella", "hooli", "stark", "wayne", "wonka"]; const TPL: [(&str, bool, bool, [f64; 6]); 8] = DEMO_TPL; diff --git a/probbit-cli/src/run.rs b/probbit-persona/src/run.rs similarity index 96% rename from probbit-cli/src/run.rs rename to probbit-persona/src/run.rs index bbb75f5..fb13675 100644 --- a/probbit-cli/src/run.rs +++ b/probbit-persona/src/run.rs @@ -2,6 +2,8 @@ //! Instructions (`--op`): `decide` (default: exact enumeration if the feasible set is <= --exact-limit, else the exact //! frontier DP if the program allows it (--frontier-states, 0 = off), else 4 gated chains), //! `exact` (enumeration only), `sample` (chains + gate, never enumerate). What-if = `"clamp"` on a variable. +#[allow(unused_imports)] +use std::prelude::rust_2021::*; use crate::json::{num, obj, str as jstr, InErr, Json}; use probbit_ir::*; @@ -185,6 +187,7 @@ pub fn from_json(j: &Json) -> Result { Some(x) } }; let (pairs, caps, compiled) = compile_parts(k, &mut h, pairs, caps); let mut m = Model::new(n, k, h, allowed, clamp, pairs, caps).map_err(|e| value("", e))?; m.start = start; + drop(vindex); // no_std: hashbrown's map drop is not `may_dangle`, so the borrow of `values` ends here, not at the scope end Ok(Prog { m, vars, values, compiled }) } @@ -245,13 +248,14 @@ fn ids(p: &Prog, mask: &[bool], want: bool) -> Json { Json::Arr(p.vars.iter().en /// Preserve the legacy full candidate `plan`, but make its release status explicit. /// `released_plan` is a projection of that same feasible candidate, not a separately /// solved complete plan; escalated variables still require a joint completion. -pub(crate) fn release_contract(doc: &mut Json) { +pub fn release_contract(doc: &mut Json) { let Some(Json::Obj(plan)) = doc.get("plan") else { return; }; let verdict = doc.get("verdict").and_then(Json::as_str).unwrap_or(""); let status = match verdict { "exact" | "diagnostics_passed" => "released", "partial" => "partial", _ => "diagnostic" }; let released: std::collections::HashSet<&str> = doc.get("released").and_then(Json::as_arr) .map_or_else(Default::default, |a| a.iter().filter_map(Json::as_str).collect()); let projection = Json::Obj(plan.iter().filter(|(id, _)| status != "diagnostic" && released.contains(id.as_str())).cloned().collect()); + drop(released); // as above: the set borrows `doc` if let Json::Obj(v) = doc { v.push(("plan_status".into(), jstr(status))); v.push(("released_plan".into(), projection)); } } @@ -336,7 +340,7 @@ fn run_inner(p: &Prog, op: &str, budget: f64, seed: u64, exact_limit: u64, polis // budget, and if a chain finds no start the exact search runs again, past its gap budget, to the end of --budget-ms: its // first plan is also the proof of infeasibility. An earlier version gave the exact tier half the budget BEFORE sampling, which // cost feasible programs sampler time (200-job schedules at 5 s: 174 -> 97 and 157 -> 0 released). `--sweeps N`: no fallback. - let fallback = op == "decide" && sweeps == 0 && !m.is_partition(); crate::tui::tier("sampler"); + let fallback = op == "decide" && sweeps == 0 && !m.is_partition(); crate::hooks::tier("sampler"); let s = match sample_on_starting(m, chains, threads, sweeps, if sweeps > 0 { None } else { Some(budget) }, seed, false, cpu_pct, mem.1, if fallback { 0.5 } else { 1.0 }) { Some(s) => s, None => { // Matching (quota-shaped programs) proves infeasibility; the bounded search for other programs does not if m.is_partition() { out.extend([("verdict", jstr("infeasible")), ("reason", jstr("no assignment satisfies every rule (capacitated matching)")), ("ms", ms(t0))]); return (obj(out), 1); } @@ -358,9 +362,9 @@ fn run_inner(p: &Prog, op: &str, budget: f64, seed: u64, exact_limit: u64, polis let hint = if op == "decide" { "no feasible start found within the search budget, and the bounded exact search (run again to the end of --budget-ms) found no plan (not a proof of infeasibility); --op exact searches exhaustively and can prove infeasibility, or raise --budget-ms" } else { "no feasible start found within the search budget (not a proof of infeasibility); try --op decide" }; out.extend([("verdict", jstr("refused")), ("reason", jstr(hint)), ("ms", ms(t0))]); return (obj(out), 3); } }; - let sample_s = ts.elapsed().as_secs_f64(); let tg = probbit_core::rt::Instant::now(); crate::tui::tier("gate"); + let sample_s = ts.elapsed().as_secs_f64(); let tg = probbit_core::rt::Instant::now(); crate::hooks::tier("gate"); let g = gate_stats(m, &s); let gate_ms = tg.elapsed().as_secs_f64() * 1e3; let rel = g.released_tasks(&GATE); let whole = g.diagnostics_passed(&GATE); - let nrel = rel.iter().filter(|&&r| r).count(); crate::tui::gate_seen(g.rhat, g.tv_bound(&GATE), if whole { m.n } else { nrel }, m.n); crate::tui::tier("polish"); *bars = crate::item_bars(&g); let verdict = if whole { "diagnostics_passed" } else if nrel > 0 { "partial" } else { "refused" }; + let nrel = rel.iter().filter(|&&r| r).count(); crate::hooks::gate_seen(g.rhat, g.tv_bound(&GATE), if whole { m.n } else { nrel }, m.n); crate::hooks::tier("polish"); *bars = item_bars(&g); let verdict = if whole { "diagnostics_passed" } else if nrel > 0 { "partial" } else { "refused" }; let mask: Vec = if whole { vec![true; m.n] } else { rel }; // plan polish (as `probbit decide`): anneal the chains' best plan through beta 2 -> 32; never worse than that plan // --polish-sweeps N = fixed-work polish (deterministic); --polish-ms is wall-clock, so its plan can vary run to run @@ -377,9 +381,15 @@ fn run_inner(p: &Prog, op: &str, budget: f64, seed: u64, exact_limit: u64, polis ("telemetry", obj([vec![("chains", num(chains as f64)), ("threads", num(threads.clamp(1, chains) as f64)), ("sweeps", num(s.sweeps as f64)), ("site_updates_per_s", num(if sample_s > 0.0 { (s.sweeps as f64 * m.n as f64 / sample_s).round() } else { 0.0 })), ("sample_ms", num(sample_s * 1e3)), ("gate_ms", num(gate_ms)), ("polish_ms", num(if polish_sweeps > 0 { 0.0 } else { polish_ms })), ("polish_sweeps", num(polish_sweeps as f64)), ("process_cpu_ms", crate::sys::usage().map_or(Json::Null, |u| num(u.0))), ("peak_rss_mb", crate::sys::usage().map_or(Json::Null, |u| num(u.1))), ("nice", crate::sys::nice().map_or(Json::Null, |v| num(v as f64))), ("cpu_limit_pct", num(cpu_pct as f64)), - ("mem_limit_mb", num(mem.0 as f64)), ("traj_rows", num(s.traj.iter().map(|tr| tr.len() / m.n).sum::() as f64))], crate::exact_cap(exact_ms, reached), + ("mem_limit_mb", num(mem.0 as f64)), ("traj_rows", num(s.traj.iter().map(|tr| tr.len() / m.n).sum::() as f64))], exact_cap(exact_ms, reached), if auto && reached { vec![("exact_budget_reached", Json::Bool(true))] } else { vec![] }, - vec![("exact_phase_ms", num((ts - t0).as_secs_f64() * 1e3)), ("polish_wall_ms", num(polish_wall))], crate::config_echo()].concat())), ("ms", ms(t0))]); + vec![("exact_phase_ms", num((ts - t0).as_secs_f64() * 1e3)), ("polish_wall_ms", num(polish_wall))], crate::hooks::config_echo()].concat())), ("ms", ms(t0))]); let code = if verdict == "refused" { 3 } else { 0 }; (obj(out), code) } +/// Telemetry fields of a sampled answer when --exact-ms was given (none otherwise): the cap and whether the exact tiers hit it. +pub fn exact_cap(xms: Option, reached: bool) -> Vec<(&'static str, Json)> { + xms.map_or(vec![], |x| vec![("exact_ms", num(x)), ("exact_ms_reached", Json::Bool(reached))]) +} +/// Per item, the error bar the gate compares with `tv_tol`: z x max(MCSE short, MCSE long) (`Gate::released_tasks`). +pub fn item_bars(g: &Gate) -> Vec { g.sig_tv.iter().zip(&g.sig_tv_long).map(|(&s, &l)| GATE.z * if s.is_nan() || l.is_nan() { f64::NAN } else { s.max(l) }).collect() } diff --git a/probbit-persona/src/strand.rs b/probbit-persona/src/strand.rs new file mode 100644 index 0000000..319d69c --- /dev/null +++ b/probbit-persona/src/strand.rs @@ -0,0 +1,601 @@ +//! The strand writer and its checks (docs/persona.md §5.7), with no file I/O: the pure half of `probbit live`. Events come in as +//! JSON objects of inputs; the clock stamps each one's `elapsed_hours` (the time since the previous event, quantised to 1e-6 h), +//! so moods decay by their half-lives while nobody is talking to it, and feedback moves the learned deltas (§2.8). Every event +//! appends one line to the strand: the inputs as the turn used them, the stance's and the new state's digests, and the sha256 of +//! the line before. The strand's header carries the persona document, the initial state and the engine version, so `verify` +//! replays the whole life from the strand alone and names the earliest line that differs, or the earliest line with a field or +//! line kind this engine does not know (`unsupported`, a newer engine's strand: not verified, and not a divergence). +//! +//! Control lines (pause, resume, retire: appended by `probbit live control`, never by an event; no credit crosses them) and +//! checkpoint lines (the whole state every K events, so a replay can start there) are written here; `verify` replays all three. +//! The writer returns each line as a `String` and the caller stores it (the CLI appends to a file under a writer lock, +//! probbit-cli/src/live.rs; a firmware writes flash). Every function here takes the strand as text, so it runs on `no_std`. +use crate::json::{self, InErr, Json}; +use crate::persona::{self, perr, Engine, Persona, State}; +#[allow(unused_imports)] +use std::prelude::rust_2021::*; + +/// The strand format +pub const FORMAT: f64 = 1.0; +/// The clock. `Real`: a monotonic clock started with the run; each event's elapsed hours are the time since the previous event +/// (the opening event: since the start). `Fixed`: time comes from the events, each carrying its own `elapsed_hours` (default 0), so a +/// run is a pure function of its events. +pub enum Clock { Real(probbit_core::rt::Instant), Fixed } +/// Hours quantised to 1e-6 h (3.6 ms): the value the turn uses is the value the strand logs +fn q6(h: f64) -> f64 { persona::r6(h) } + +/// A strand's life as its control lines leave it: events are answered only while `Active` +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Status { Active, Paused, Retired } +impl Status { + pub fn name(self) -> &'static str { match self { Status::Active => "active", Status::Paused => "paused", Status::Retired => "retired" } } + /// The status a control line leads to: pause an active individual, resume a paused one, retire either; nothing after retire + pub fn after(self, what: &str) -> Result { + match (what, self) { + (_, Status::Retired) => Err(refusal(Status::Retired)), + ("pause", Status::Active) => Ok(Status::Paused), ("resume", Status::Paused) => Ok(Status::Active), ("retire", _) => Ok(Status::Retired), + // RFC 0004 amendment A2: `erase` returns the learned weights to zero and leaves the status as it was + ("erase", s) => Ok(s), + ("pause", _) => Err(perr("control", "already paused")), ("resume", _) => Err(perr("control", "not paused (resume follows a pause)")), + _ => Err(perr("control", "pause | resume | retire")) } + } +} +/// The refusal of an event (or a control line) by a paused or retired individual: code `paused` / `retired` (`live` exits 4) +fn refusal(s: Status) -> InErr { + let msg = if s == Status::Retired { "the individual is retired: every event is refused, for good (probbit live control ... retire)" } + else { "the individual is paused: every event is refused until `probbit live control STRAND resume`" }; + InErr { code: s.name(), path: "strand".into(), msg: msg.into() } +} +/// The default number of events between two checkpoint lines +pub const CHECKPOINT_EVERY: u64 = 1000; + +/// A live individual: the persona, the current state, the clock, the strand's chain and its status (control lines) +pub struct Live { pub p: Persona, pub st: State, clock: Clock, last: f64, prev: String, pub n: u64, pub status: Status, pub controls: u64, pub checkpoints: u64, pub rests: u64, + /// RFC 0009: the expectation's memory (a fold of the logged events) and the last event's exact trajectory (derived, never logged) + pub expect: Option, pub traj: Option } +/// A document with its keys sorted at every level (`persona::text` of it is its canonical JSON) +fn sorted(j: &Json) -> Json { + match j { Json::Obj(v) => { let mut o: Vec<(String, Json)> = v.iter().map(|(k, x)| (k.clone(), sorted(x))).collect(); o.sort_by(|a, b| a.0.cmp(&b.0)); Json::Obj(o) } + Json::Arr(v) => Json::Arr(v.iter().map(sorted).collect()), x => x.clone() } +} +/// One line of a strand without its line ending: strands are written with `\n`; a `\r\n` copy (Windows text mode, an editor) +/// reads the same, since a canonical JSON line never ends in `\r` itself +fn bare(l: &str) -> &str { l.strip_suffix('\r').unwrap_or(l) } +impl Live { + /// Start a life from `st` (an `init` state or a stored one) -> (the individual, the strand's header line). The header keeps + /// the persona document in its own key order (the compiled program follows it, and `engine.program` with it) and the + /// state as canonical JSON (keys sorted), so a state read from a file and the same state from `init` give one header; the + /// header is compact JSON, not key-sorted. + pub fn start(p: Persona, doc: &Json, st: State, clock: Clock, engine: &str) -> (Live, String) { + let s = |x: &str| Json::Str(x.to_string()); + let header = persona::text(&Json::Obj(vec![("probbit_strand".into(), Json::Num(FORMAT)), ("engine".into(), s(engine)), + ("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)), ("state".into(), sorted(&st.to_json(&p))), ("document".into(), doc.clone())])); + let prev = persona::digest_of(&header); + let expect = p.expect.as_ref().map(|_| persona::ExpectMem::new()); + (Live { p, st, clock, last: 0.0, prev, n: 0, status: Status::Active, controls: 0, checkpoints: 0, rests: 0, expect, traj: None }, header) + } + /// One event: stamp its elapsed hours, run the turn, chain the strand line -> (the stance document, the strand line). A bad + /// event is an error and changes nothing (no turn, no line); so is every event while the individual is paused or retired. + pub fn event(&mut self, ev: &Json, eng: Engine) -> Result<(Json, String), InErr> { + if self.status != Status::Active { return Err(refusal(self.status)); } + let Json::Obj(kv) = ev else { return Err(perr("event", "must be a JSON object of inputs")) }; + let mut kv: Vec<(String, Json)> = kv.iter().filter(|(k, _)| k != "elapsed_hours").cloned().collect(); + let given = ev.get("elapsed_hours"); + let (elapsed, now) = match &self.clock { + Clock::Real(t0) => { if given.is_some() { return Err(perr("event.elapsed_hours", "the clock stamps it (--clock fixed reads it from the events)")); } + let now = t0.elapsed().as_secs_f64() / 3600.0; (q6((now - self.last).max(0.0)), now) } + Clock::Fixed => { let e = match given { None => 0.0, Some(Json::Num(x)) if x.is_finite() && *x >= 0.0 => *x, _ => return Err(perr("event.elapsed_hours", "a number >= 0")) }; (q6(e), self.last) } }; + kv.push(("elapsed_hours".into(), Json::Num(elapsed))); + let inputs = Json::Obj(kv); + // RFC 0009: the deviation and the trajectory are read from the state before the event (effects {}: the turn is as without them) + let dev = self.expect.as_ref().and_then(|m| persona::deviation(&self.p, &self.st, &inputs, m, eng)); + let traj = persona::trajectory(&self.p, &self.st, &inputs); + let (mut stance, ns) = persona::turn(&self.p, &self.st, &inputs, false, eng, false)?; + let sd = persona::stance_sha(&stance); + if self.expect.is_some() { if let Json::Obj(v) = &mut stance { v.push(("deviation".into(), dev.as_ref().map_or(Json::Null, persona::Deviation::to_json))); } } + // §2.18: the glyph reads the finished document, `deviation` included (the surprise modifier) + persona::set_glyph(&self.p, &mut stance); + // RFC 0002 part 2: a rest line due before this event is its own line, chained before the event line (the turn applied it) + let (rest, prev) = match persona::rest_line(&self.p, &self.st, &inputs) { + Some((r, rs)) => { let l = rest_line(&self.prev, r, &rs.digest); let d = persona::digest_of(&l); (Some(l), d) } + None => (None, self.prev.clone()) }; + let mut lj = vec![("n".into(), Json::Num((self.n + 1) as f64)), ("inputs".into(), inputs.clone()), ("stance".into(), Json::Str(sd)), + ("state".into(), Json::Str(ns.digest.clone())), ("prev".into(), Json::Str(prev))]; + if let Some(d) = &dev { lj.push(("deviation".into(), d.receipt())); } + if let Some(g) = stance.get("glyph") { lj.push(("glyph".into(), g.clone())); } + let line = persona::canon(&Json::Obj(lj)); + if let Some(m) = self.expect.as_mut() { persona::expect_step(&self.p, m, &inputs, dev.as_ref().and_then(persona::Deviation::logged_pred)); } + self.traj = traj; + self.n += 1; self.last = now; self.prev = persona::digest_of(&line); self.st = ns; + if let Some(r) = &rest { self.rests += 1; return Ok((stance, format!("{r}\n{line}"))); } + Ok((stance, line)) + } + /// A control line (pause, resume or retire) `by` a person or a sensor (`human[:id]`, `env[:id]`) for `reason`, stamped `at`: + /// the status moves, the credit is zeroed (no feedback after the line credits a stance before it) and the line is chained -> + /// the line. Nothing else changes: moods, drives, learned weights and the clock are the individual's as before. + pub fn control(&mut self, what: &str, by: &str, reason: &str, at: &str) -> Result { + let to = self.status.after(what)?; + let line = control_line(&self.prev, what, by, reason, at)?; + self.st = self.st.zero_credit(&self.p); if what == "erase" { self.st = self.st.erased(&self.p); } self.status = to; self.controls += 1; self.prev = persona::digest_of(&line); + Ok(line) + } + /// A checkpoint line after event n: the event count, the whole state after it and that event's stance document, chained -> + /// the line. A replay can start there (the state) and a board can be drawn there (the stance) without the events before. + pub fn checkpoint(&mut self, stance: &Json) -> String { + let line = persona::canon(&Json::Obj(vec![("checkpoint".into(), Json::Num(self.n as f64)), ("prev".into(), Json::Str(self.prev.clone())), ("stance".into(), stance.clone()), + ("state".into(), self.st.to_json(&self.p))])); + self.checkpoints += 1; self.prev = persona::digest_of(&line); line + } + /// The sha256 of the strand's last line (the header's before any event) + pub fn head(&self) -> &str { &self.prev } +} +/// A control line after the line whose sha256 is `prev` (canonical JSON: at, by, control, prev, reason). `by` names who: a +/// person or a sensor, never the individual or the clock; the reason is 1-500 characters. +pub fn control_line(prev: &str, what: &str, by: &str, reason: &str, at: &str) -> Result { + if !["pause", "resume", "retire", "erase"].contains(&what) { return Err(perr("control", "pause | resume | retire")); } + if !matches!(persona::src_kind(by), Some("human" | "env")) { return Err(perr("control.by", "who: human[:id] or env[:id] (id: 1-64 of A-Z a-z 0-9 _ . - @ / :); the individual cannot control itself")); } + if reason.trim().is_empty() || reason.chars().count() > 500 { return Err(perr("control.reason", "1-500 characters")); } + if at.is_empty() || at.len() > 64 { return Err(perr("control.at", "a time stamp, 1-64 characters (RFC 3339 by default)")); } + let s = |x: &str| Json::Str(x.to_string()); + Ok(persona::canon(&Json::Obj(vec![("at".into(), s(at)), ("by".into(), s(by)), ("control".into(), s(what)), ("prev".into(), s(prev)), ("reason".into(), s(reason))]))) +} +/// A rest line (RFC 0002 part 2) after the line whose sha256 is `prev` (canonical JSON: prev, rest, state): what the line did +/// (downscale, prune, the pressure, the learned weights after it, each gate as kept) and the digest of the state after it. It is +/// written only together with the event it precedes, and that event's line chains to it. +fn rest_line(prev: &str, rest: Json, state: &str) -> String { + persona::canon(&Json::Obj(vec![("prev".into(), Json::Str(prev.into())), ("rest".into(), rest), ("state".into(), Json::Str(state.into()))])) +} +/// What kind of strand line `j` is +pub enum Kind { Event, Control, Checkpoint, Rest } +pub fn kind(j: &Json) -> Kind { if j.get("control").is_some() { Kind::Control } else if j.get("checkpoint").is_some() { Kind::Checkpoint } else if j.get("rest").is_some() { Kind::Rest } else { Kind::Event } } +/// The header's keys, as this engine writes them +const HEADER: [&str; 6] = ["probbit_strand", "engine", "persona", "seed", "state", "document"]; +/// The earliest key of a line after the header that its kind does not have (the line's keys in canonical order): a line kind +/// this engine does not know (a line without `control` or `checkpoint` is read as an event line, so `{"prev": ..., "nap": ...}` +/// gives `nap`) or a field it does not know on a known one +fn unknown_key(j: &Json) -> Option<&str> { + let known: &[&str] = match kind(j) { Kind::Event => &["deviation", "glyph", "inputs", "n", "prev", "stance", "state"], Kind::Control => &["at", "by", "control", "prev", "reason"], + Kind::Checkpoint => &["checkpoint", "prev", "stance", "state"], Kind::Rest => &["prev", "rest", "state"] }; + j.as_obj()?.iter().map(|(k, _)| k.as_str()).find(|k| !known.contains(k)) +} +/// The start of a replay's `why` that is not a divergence: the line has a field or a line kind this engine does not know (a +/// strand from a newer engine). Nothing from that line on was checked: the strand is not verified, and not shown to differ. +/// `verify` reports it as `unsupported` (exit 5), never as `diverges`; a changed value of a field this engine knows still +/// diverges. +pub const UNSUPPORTED: &str = "unsupported by this engine: "; +/// The `why` of an unsupported field: UNSUPPORTED, then its path (a persona field as the persona validator names it, a state +/// field under `state.`, otherwise its path in the strand line) +fn unsupported(path: &str) -> String { format!("{UNSUPPORTED}{path}") } +/// The field path of an unsupported `why`; None for a divergence +pub fn unsupported_field(why: &str) -> Option<&str> { why.strip_prefix(UNSUPPORTED) } +/// A persona, state or event error for a field this engine does not know (the validators' "unknown field" at its path) -> the +/// unsupported `why`; any other error -> `what` + the error, a divergence +fn read_err(e: InErr, what: &str) -> String { if e.msg.starts_with("unknown field") { unsupported(&e.path) } else { format!("{what}{}: {}", e.path, e.msg) } } +/// Now as RFC 3339 UTC to the second (the control lines' default `at`); `std` only (a firmware passes its own `at`) +#[cfg(feature = "std")] +pub fn utc_now() -> String { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + let (z, t) = ((secs / 86_400) as i64 + 719_468, secs % 86_400); // days since 0000-03-01 (civil-from-days) + let (era, doe) = (z.div_euclid(146_097), z.rem_euclid(146_097)); let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; let y = yoe + era * 400 + i64::from(m <= 2); + format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", t / 3600, t % 3600 / 60, t % 60) +} + +/// A strand replayed line by line: `verify` and `probbit monitor` (§5.8) share it, so both read a strand with one set of +/// rules. `open` checks the header and rebuilds the individual; each `step` replays one event line on the fixed clock and +/// checks it (`prev`, `n`, the stance and state digests, the bytes). After an error the replay is spent: the chain past the +/// line that differs cannot be checked. +pub struct Replay { pub live: Live, pub header: Json, pub doc: Json, pub line: usize, pub from: Option, last: Option, checkpoint_ready: bool, norms: Normals, at_cap: Vec, seen: (f64, Vec), viab: Option<(f64, Option)>, rest: Option } +/// A senses block's normals over the replay (§2.11): per sense the means after the last 50 events, the largest move of a mean +/// within 50 events so far, and the latest event's move (the monitor's alert: over drift_cap / 2) +#[derive(Default)] +pub struct Normals { window: Vec>, pub max_move: Vec, pub moved: Vec } +impl Normals { + fn start(p: &Persona, st: &State) -> Normals { + let n = persona::normals(p, st); + Normals { window: n.iter().map(|x| std::collections::VecDeque::from(vec![x.1])).collect(), max_move: vec![0.0; n.len()], moved: vec![0.0; n.len()] } + } + fn track(&mut self, p: &Persona, st: &State) { + for (k, (_, m, _, _)) in persona::normals(p, st).iter().enumerate() { let q = &mut self.window[k]; + let mv = persona::r6(q.iter().fold(0.0f64, |a, x| a.max((m - x).abs()))); + self.moved[k] = mv; self.max_move[k] = self.max_move[k].max(mv); q.push_back(*m); if q.len() > 50 { q.pop_front(); } } + } +} +impl Replay { + /// The header line (without its line ending) -> the replay, ready for event lines; Err((1, what differs)), or Err((1, the + /// unsupported `why`)) for a header key, persona field or state field this engine does not know + pub fn open(head: &str) -> Result { + let h = json::parse(head).map_err(|e| (1, format!("the header is not JSON: {}", e.msg)))?; + if h.get("probbit_strand").and_then(Json::as_f64) != Some(FORMAT) { return Err((1, "not a probbit strand (format 1)".into())); } + if let Some((k, _)) = h.as_obj().and_then(|kv| kv.iter().find(|(k, _)| !HEADER.contains(&k.as_str()))) { return Err((1, unsupported(k))); } + let doc = h.get("document").cloned().ok_or((1, "the header has no persona document".to_string()))?; + let p = persona::build(&doc).map_err(|e| (1, read_err(e, "the persona document: ")))?; + if h.get("persona").and_then(|x| x.get("digest")).and_then(Json::as_str) != Some(p.digest.as_str()) { return Err((1, "the persona digest does not match the document".into())); } + let st0 = State::read(&p, h.get("state").unwrap_or(&Json::Null)).map_err(|e| (1, read_err(e, "the initial state: ")))?; + let (live, header) = Live::start(p, &doc, st0, Clock::Fixed, h.get("engine").and_then(Json::as_str).unwrap_or("")); + if header != head { return Err((1, "the header is not as written".into())); } + let norms = Normals::start(&live.p, &live.st); let at_cap = vec![0.0; persona::deficits(&live.p, &live.st).len()]; let seen = (0.0, vec![0.0; persona::gate_gains(&live.p, &live.st).len()]); + let viab = viab_start(&live); + Ok(Replay { live, header: h, doc, line: 1, from: None, last: None, checkpoint_ready: false, norms, at_cap, seen, viab, rest: None }) + } + /// The replay from a checkpoint line (`cp`, without its line ending, at 1-based line `at`; `before` = the event line before it) + /// instead of the header: the header is read as by `open`; the checkpoint must follow `before` (prev), at its event count, with + /// its stance (the digest `before` logs) and its state (read as any state is, with the digest `before` logs); the chain goes on + /// from the checkpoint line. The lines before it are taken as written: a full `verify` replays them and checks the checkpoint. + pub fn open_at(head: &str, before: &str, cp: &str, at: usize) -> Result { + let mut r = Replay::open(head)?; + // RFC 0009: the expectation's memory is a fold of every event line, so a strand with the block replays from its header + if r.live.p.expect.is_some() { return Err((at, "the persona's expectation block reads every event before this checkpoint: verify this strand from its header".into())); } + let j = json::parse(cp).map_err(|e| (at, format!("not JSON: {}", e.msg)))?; + let b = json::parse(before).map_err(|e| (at - 1, format!("not JSON: {}", e.msg)))?; + if persona::canon(&j) != cp { return Err((at, "not canonical JSON".into())); } + if let Some(k) = unknown_key(&j) { return Err((at, unsupported(k))); } + if !j.as_obj().is_some_and(|kv| kv.len() == 4 && kv.iter().all(|(k, _)| ["checkpoint", "prev", "stance", "state"].contains(&k.as_str()))) { + return Err((at, "checkpoint fields must be checkpoint, prev, stance and state".into())); + } + // §2.18: an event line of a persona with glyphs also carries `glyph` + if !b.as_obj().is_some_and(|kv| kv.len() == 5 + usize::from(kv.iter().any(|(k, _)| k == "glyph")) && kv.iter().all(|(k, _)| ["n", "inputs", "prev", "stance", "state", "glyph"].contains(&k.as_str()))) { + return Err((at - 1, "a checkpoint must immediately follow an event line".into())); + } + if j.get("prev").and_then(Json::as_str) != Some(persona::digest_of(before).as_str()) { return Err((at, "prev is not the sha256 of the line before".into())); } + let n = j.get("checkpoint").and_then(Json::as_f64).filter(|x| *x >= 1.0 && x.fract() == 0.0).ok_or((at, "not a checkpoint line".to_string()))? as u64; + if b.get("n").and_then(Json::as_f64) != Some(n as f64) { return Err((at, format!("the line before is not event {n}"))); } + let stance = j.get("stance").cloned().unwrap_or(Json::Null); + if b.get("stance").and_then(Json::as_str) != Some(persona::stance_sha(&stance).as_str()) { return Err((at, "the checkpoint's stance is not the one event {n} logs".replace("{n}", &n.to_string()))); } + let st = State::read(&r.live.p, j.get("state").unwrap_or(&Json::Null)).map_err(|e| (at, read_err(e, "the checkpoint's state: ")))?; + if b.get("state").and_then(Json::as_str) != Some(st.digest.as_str()) { return Err((at, format!("the checkpoint's state is not the one event {n} logs"))); } + r.live.st = st; r.live.n = n; r.live.prev = persona::digest_of(cp); r.live.checkpoints = 1; r.line = at; r.from = Some(n); r.last = Some(stance); r.checkpoint_ready = false; + r.norms = Normals::start(&r.live.p, &r.live.st); r.at_cap = vec![0.0; r.at_cap.len()]; r.seen = (0.0, vec![0.0; r.seen.1.len()]); r.viab = viab_start(&r.live); + Ok(r) + } + /// The stance document of the last event replayed (or of the checkpoint the replay started from) + pub fn last_stance(&self) -> Option<&Json> { self.last.as_ref() } + /// The senses' normals over the replay so far (§2.11) + pub fn normals(&self) -> &Normals { &self.norms } + /// One line (without its line ending) -> the stance document an event line replays to (None for a control or checkpoint + /// line); Err((its 1-based line number, what differs)). A control line must be a valid move of the status and the line its + /// fields give; a checkpoint line must immediately follow an event while active and carry + /// the event count and the state the replay reached. The last stance remains available to + /// the monitor after controls/checkpoints, but that does not authorize another checkpoint. A chained line with a key its + /// kind does not have (a newer line kind, a newer field), or an event whose inputs have a field this engine does not know, + /// is Err((its line number, the unsupported `why`)). + pub fn step(&mut self, l: &str, eng: Engine) -> Result, (usize, String)> { + let (i, live) = (self.line + 1, &mut self.live); + let j = json::parse(l).map_err(|e| (i, format!("not JSON: {}", e.msg)))?; + if persona::canon(&j) != l { return Err((i, "not canonical JSON".into())); } + let head = self.rest.as_deref().map_or(live.head().to_string(), persona::digest_of); + if j.get("prev").and_then(Json::as_str) != Some(head.as_str()) { return Err((i, PREV.into())); } + if let Some(k) = unknown_key(&j) { return Err((i, unsupported(k))); } + if self.rest.is_some() && !matches!(kind(&j), Kind::Event) { return Err((i, "a rest line must be followed by its event line".into())); } + match kind(&j) { + // RFC 0002 part 2: a rest line is checked with the event after it (the replay recomputes it there) + Kind::Rest => { if live.status != Status::Active { return Err((i, format!("rest line while {}", live.status.name()))); } + self.rest = Some(l.to_string()); self.line = i; self.checkpoint_ready = false; return Ok(None) } + Kind::Control => { let f = |k: &str| j.get(k).and_then(Json::as_str).unwrap_or(""); + let line = live.control(f("control"), f("by"), f("reason"), f("at")).map_err(|e| (i, format!("control line: {}: {}", e.path, e.msg)))?; + if line != l { return Err((i, "the control line differs".into())); } + self.line = i; self.checkpoint_ready = false; return Ok(None) } + Kind::Checkpoint => { + if live.status != Status::Active { return Err((i, format!("checkpoint while {}", live.status.name()))); } + if !self.checkpoint_ready { return Err((i, "a checkpoint must immediately follow an event line".into())); } + if j.get("checkpoint").and_then(Json::as_f64) != Some(live.n as f64) { return Err((i, format!("the checkpoint is not at event {}", live.n))); } + let Some(stance) = self.last.as_ref() else { return Err((i, "a checkpoint follows an event line".into())) }; + if live.checkpoint(stance) != l { return Err((i, "the checkpoint's state or stance differs from the replay".into())); } + self.line = i; self.checkpoint_ready = false; return Ok(None) } + Kind::Event => {} } + if j.get("n").and_then(Json::as_f64) != Some((live.n + 1) as f64) { return Err((i, format!("n is not {}", live.n + 1))); } + let ev = j.get("inputs").ok_or((i, "no inputs".to_string()))?; + // a needs block (RFC 0002): the stamped hours of events that found a deficit at its cap (it sat there since the event before) + let before = persona::deficits(&live.p, &live.st); let h = ev.get("elapsed_hours").and_then(Json::as_f64).unwrap_or(0.0); + let logged = self.rest.take(); + let (stance, both) = live.event(ev, eng).map_err(|e| (i, read_err(e, "")))?; + let line = match (both.split_once('\n'), logged) { + (Some((r, e)), Some(lr)) => { if r != lr { return Err((i - 1, "the rest line differs".into())); } e.to_string() } + (Some(_), None) => return Err((i, "a rest line is due before this event and missing".into())), + (None, Some(_)) => return Err((i - 1, "a rest line where none is due".into())), + (None, None) => both }; + for (k, (_, x, cap)) in before.iter().enumerate() { if *x >= cap - 5e-7 { self.at_cap[k] = persona::r6(self.at_cap[k] + h); } } + if j.get("stance").and_then(Json::as_str) != Some(persona::stance_sha(&stance).as_str()) { return Err((i, "the stance differs".into())); } + // RFC 0009: the logged deviation is recomputed (an edited receipt diverges here) + if json::parse(&line).ok().and_then(|x| x.get("deviation").cloned()) != j.get("deviation").cloned() { return Err((i, "the deviation differs".into())); } + // §2.18: the logged glyph is recomputed from the replayed stance (an edited glyph diverges here) + if stance.get("glyph") != j.get("glyph") { return Err((i, "the glyph differs".into())); } + if j.get("state").and_then(Json::as_str) != Some(live.st.digest.as_str()) { return Err((i, "the state differs".into())); } + if line != l { return Err((i, "the line differs".into())); } + self.line = i; self.last = Some(stance.clone()); self.checkpoint_ready = true; self.norms.track(&self.live.p, &self.live.st); + // RFC 0002: the largest R̄ (Design C) and per learned trait the largest gain (Design B) after any event replayed + if let Some((r, _, _)) = persona::pace_rbar(&self.live.p, &self.live.st) { self.seen.0 = self.seen.0.max(r); } + for (k, (_, g, _)) in persona::gate_gains(&self.live.p, &self.live.st).iter().enumerate() { if let Some(m) = self.seen.1.get_mut(k) { *m = m.max(*g); } } + // RFC 0008 E: the smallest margin_min after any event replayed, and the event at which viability was lost + if let (Some((seen, lost_at)), Some((_, m, lost))) = (self.viab.as_mut(), persona::viability(&self.live.p, &self.live.st)) { + *seen = seen.min(m); if lost && lost_at.is_none() { *lost_at = Some(self.live.n); } } + Ok(Some(stance)) + } + /// The end of the strand: a rest line (RFC 0002 part 2) is only ever written with the event after it, so a strand that ends + /// on one diverges at it + pub fn end(&self) -> Result<(), (usize, String)> { + if self.rest.is_some() { return Err((self.line, "a rest line must be followed by its event line".into())); } Ok(()) + } + /// `verify`'s summary of the lines replayed so far: `engine` is the strand header's (the engine that wrote it), `verified_by` + /// the engine that replayed it + pub fn summary(&self) -> Json { + let (s, live) = (|x: &str| Json::Str(x.to_string()), &self.live); + let mut v = vec![("ok".into(), Json::Bool(true)), ("events".into(), Json::Num(live.n as f64)), ("persona".into(), s(&live.p.name)), ("seed".into(), Json::Num(live.st.seed as f64)), + ("engine".into(), self.header.get("engine").cloned().unwrap_or(Json::Null)), ("verified_by".into(), Json::Str(engine())), ("final_state".into(), s(&live.st.digest)), + ("last_line".into(), s(live.head()))]; + // a strand with control or checkpoint lines says so (a strand without them gives the summary of before) + if live.controls > 0 { v.push(("controls".into(), Json::Num(live.controls as f64))); v.push(("status".into(), s(live.status.name()))); } + if live.checkpoints > 0 { v.push(("checkpoints".into(), Json::Num(live.checkpoints as f64))); } + // RFC 0002 part 2: rest lines replayed, the pressure now; the load now + if live.rests > 0 { v.push(("rest_lines".into(), Json::Num(live.rests as f64))); } + if let Some((sp, pend)) = persona::rest_pressure(&live.st) { v.push(("pressure".into(), Json::Obj(vec![("s".into(), Json::Num(sp)), ("pending".into(), Json::Bool(pend))]))); } + if let Some((l, _)) = persona::load_level(&live.p, &live.st) { v.push(("load".into(), Json::Obj(vec![("l".into(), Json::Num(l))]))); } + if let Some(n) = self.from { v.push(("from_checkpoint".into(), Json::Num(n as f64))); } + // a senses block (§2.11): per sense the normal now and the largest move of its mean within 50 events + let nm = persona::normals(&live.p, &live.st); + if !nm.is_empty() { v.push(("normals".into(), Json::Obj(nm.iter().enumerate().map(|(k, (id, m, n, _))| (id.clone(), Json::Obj(vec![("mean".into(), Json::Num(*m)), + ("n".into(), Json::Num(*n as f64)), ("max_move_50".into(), Json::Num(self.norms.max_move.get(k).copied().unwrap_or(0.0)))]))).collect()))); } + // a needs block (RFC 0002): per need its deficit now and the stamped hours it sat at its cap + let dx = persona::deficits(&live.p, &live.st); + if !dx.is_empty() { v.push(("needs".into(), Json::Obj(dx.iter().enumerate().map(|(k, (id, x, _))| (id.clone(), Json::Obj(vec![("deficit".into(), Json::Num(*x)), + ("at_cap_hours".into(), Json::Num(self.at_cap.get(k).copied().unwrap_or(0.0)))]))).collect()))); } + // RFC 0002 Design C: R̄ now and the largest after any event replayed; Design B: per learned trait the largest gain seen + if let Some((r, _, _)) = persona::pace_rbar(&live.p, &live.st) { v.push(("pace".into(), Json::Obj(vec![("rbar".into(), Json::Num(r)), ("max".into(), Json::Num(self.seen.0))]))); } + let gg = persona::gate_gains(&live.p, &live.st); + if !gg.is_empty() { v.push(("surprise".into(), Json::Obj(gg.iter().enumerate().map(|(k, (t, _, _))| (t.clone(), + Json::Obj(vec![("g_max_seen".into(), Json::Num(self.seen.1.get(k).copied().unwrap_or(0.0)))]))).collect()))); } + // RFC 0008 E: with a bound, recomputed from the logged readings and amounts + if let Some((seen, lost_at)) = self.viab { v.push(("viability".into(), Json::Obj(vec![("margin_min_seen".into(), Json::Num(seen)), ("lost_at".into(), lost_at.map_or(Json::Null, |n| Json::Num(n as f64)))]))); } + // a credit block (§2.8): the decisions still awaiting an outcome + if let Some(o) = persona::open_decisions(&live.p, &live.st) { v.push(("open".into(), Json::Arr(o.iter().map(|(t, _)| Json::Num(*t as f64)).collect()))); } + Json::Obj(v) + } +} +/// RFC 0008 E: the viability record a replay starts from: the margin_min of its first state, and lost_at (the event count when the +/// state is already lost, as after a checkpoint); none without a bound +fn viab_start(live: &Live) -> Option<(f64, Option)> { persona::viability(&live.p, &live.st).map(|(_, m, lost)| (m, lost.then_some(live.n))) } +/// Per event line its n and its block +pub type Blocks = Vec<(u64, String)>; +/// `live verify --blocks`: replay from the header -> (the summary, per event line its n and the block its stance renders, RFC 0008 E) +pub fn verify_blocks(text: &str, eng: Engine) -> Result<(Json, Blocks), (usize, String)> { + let lines = lines(text); + let replay = || -> Result<(Json, Blocks), (usize, String)> { + let mut r = Replay::open(lines.first().copied().unwrap_or(""))?; let mut out = vec![]; + for l in lines.iter().skip(1) { if let Some(doc) = r.step(l, eng)? { out.push((r.live.n, persona::block(&r.live.p, &doc))); } } + Ok((r.summary(), out)) }; + replay().map_err(|e| chain_first(&lines, e)) +} +/// A strand's text -> its lines without their line endings (a final newline ends the last line; it does not start another). +/// One leading UTF-8 byte-order mark is skipped, as `\r` before `\n` is: a Windows editor adds both on save, and neither is +/// part of the line text the chain hashes. +pub fn lines(text: &str) -> Vec<&str> { + let mut lines: Vec<&str> = text.strip_prefix('\u{feff}').unwrap_or(text).split('\n').map(bare).collect(); + if lines.last() == Some(&"") { lines.pop(); } + lines +} +/// `probbit live verify`: replay a strand from its header -> Ok(summary) when every line is as recorded; Err((1-based line +/// number, what differs)) at the earliest line that is not. Lines end in `\n` or `\r\n`; the chain hashes the line text alone. +/// A line with a field or line kind this engine does not know stops the replay as unsupported, unless a later line breaks the +/// chain (`chain_first`). +pub fn verify(text: &str, eng: Engine) -> Result { + let lines = lines(text); + let replay = || -> Result { + let mut r = Replay::open(lines.first().copied().unwrap_or(""))?; + for l in lines.iter().skip(1) { r.step(l, eng)?; } + r.end()?; Ok(r.summary()) }; + replay().map_err(|e| chain_first(&lines, e)) +} +/// The divergence the hash chain shows after an unsupported line: a strand this engine cannot replay to the end is still checked +/// for what the format alone says, so an edited, removed or reordered line after it (or the unsupported line itself, edited: +/// the next line's `prev`) diverges at the earliest line that breaks the chain (not JSON, not canonical JSON, or a `prev` that is +/// not the sha256 of the line before), as in a replay. `unsupported` is left only for a strand whose chain is whole; a +/// divergence passes unchanged. +fn chain_first(lines: &[&str], (line, why): (usize, String)) -> (usize, String) { + if unsupported_field(&why).is_none() { return (line, why); } + for i in line.max(1)..lines.len() { + let j = match json::parse(lines[i]) { Ok(j) => j, Err(e) => return (i + 1, format!("not JSON: {}", e.msg)) }; + if persona::canon(&j) != lines[i] { return (i + 1, "not canonical JSON".into()); } + if j.get("prev").and_then(Json::as_str) != Some(persona::digest_of(lines[i - 1]).as_str()) { return (i + 1, PREV.into()); } + } + (line, why) +} +/// The divergence of a line whose `prev` is not the sha256 of the line before +const PREV: &str = "prev is not the sha256 of the line before (a line before it was changed, removed or reordered)"; +/// The 0-based index of the strand's last checkpoint line followed by at least `after` lines, if any (a cheap scan: the key) +pub fn last_checkpoint(lines: &[&str], after: usize) -> Option { + (1..lines.len().saturating_sub(after)).rev().find(|&i| lines[i].starts_with(r#"{"checkpoint":"#)) +} +/// `verify --from-checkpoint`: replay from the last checkpoint line (the header read as always), not from the header +pub fn verify_from_checkpoint(text: &str, eng: Engine) -> Result { + let lines = lines(text); + let Some(k) = last_checkpoint(&lines, 0) else { return verify(text, eng) }; + let replay = || -> Result { + let mut r = Replay::open_at(lines[0], lines[k - 1], lines[k], k + 1)?; + for l in lines.iter().skip(k + 1) { r.step(l, eng)?; } + r.end()?; Ok(r.summary()) }; + replay().map_err(|e| chain_first(&lines, e)) +} +/// `verify` from the header, keeping the stance documents of the given turns (what `--actions` compares with) +pub fn verify_keep(text: &str, eng: Engine, keep: &[u64]) -> Result<(Json, Vec<(u64, Json)>, Persona), (usize, String)> { + let lines = lines(text); + let replay = || -> Result<(Json, Vec<(u64, Json)>, Persona), (usize, String)> { + let mut r = Replay::open(lines.first().copied().unwrap_or(""))?; let mut kept = vec![]; + for l in lines.iter().skip(1) { if let Some(doc) = r.step(l, eng)? { + if let Some(t) = doc.get("turn").and_then(Json::as_f64).map(|x| x as u64).filter(|t| keep.contains(t)) { kept.push((t, doc)); } } } + r.end()?; Ok((r.summary(), kept, r.live.p.clone())) }; + replay().map_err(|e| chain_first(&lines, e)) +} +/// One line of a host's action log (`live verify --actions FILE`, §5.7): the decision's turn and, per trait the host acted on, +/// the level it acted on +pub struct Action { pub line: usize, pub turn: u64, pub levels: Vec<(String, String)> } +/// A host's action log: one JSON object per line, {"turn": T, "action": {trait: level, ...}} (blank lines skipped) +pub fn read_actions(text: &str) -> Result, InErr> { + let mut out = vec![]; + for (i, l) in text.split('\n').map(bare).enumerate() { if l.trim().is_empty() { continue; } let path = format!("actions.line{}", i + 1); + let bad = |m: &str| perr(&path, m.to_string()); + let j = json::parse(l).map_err(|e| perr(&path, format!("not JSON: {}", e.msg)))?; + let kv = j.as_obj().filter(|kv| kv.len() == 2 && kv.iter().all(|(k, _)| k == "turn" || k == "action")).ok_or_else(|| bad("an object {\"turn\": T, \"action\": {trait: level}}"))?; + let turn = kv.iter().find(|(k, _)| k == "turn").and_then(|(_, x)| x.as_f64()).filter(|x| *x >= 0.0 && x.fract() == 0.0 && *x <= 9_007_199_254_740_992.0) + .ok_or_else(|| bad("turn: a whole number >= 0 (the stance's turn)"))? as u64; + let act = kv.iter().find(|(k, _)| k == "action").and_then(|(_, x)| x.as_obj()).filter(|a| !a.is_empty()).ok_or_else(|| bad("action: an object trait -> level"))?; + let levels = act.iter().map(|(t, x)| x.as_str().map(|l| (t.clone(), l.to_string())).ok_or_else(|| bad("action: each level a string"))).collect::, InErr>>()?; + out.push(Action { line: i + 1, turn, levels }); } + Ok(out) +} +/// The earliest turn (then the earliest line) whose host action differs from the level its stance released -> (actions +/// checked, the difference: {turn, line, trait, action, released (null: not released or no such turn), why}); an action on a +/// trait the persona does not have is an input error +pub fn first_difference(p: &Persona, actions: &[Action], stances: &[(u64, Json)]) -> Result<(usize, Option), InErr> { + let mut order: Vec<&Action> = actions.iter().collect(); order.sort_by_key(|a| (a.turn, a.line)); + let s = |x: &str| Json::Str(x.to_string()); let mut checked = 0; + for a in order { checked += 1; let doc = stances.iter().find(|(t, _)| *t == a.turn).map(|x| &x.1); + for (t, level) in &a.levels { + let e = match doc { Some(d) => persona::doc_entry(p, d, t), None => None }; + if doc.is_some() && e.is_none() { return Err(perr(&format!("actions.line{}.action.{t}", a.line), "not a trait of the persona")); } + let released = e.as_ref().filter(|e| e.get("released") == Some(&Json::Bool(true))).and_then(|e| e.get("level")).and_then(Json::as_str).map(str::to_string); + let why = match (doc, &released) { (None, _) => "the strand has no stance with this turn", (_, None) => "the stance did not release this trait", + (_, Some(r)) if r != level => "the host acted on another level", _ => continue }; + return Ok((checked, Some(Json::Obj(vec![("turn".into(), Json::Num(a.turn as f64)), ("line".into(), Json::Num(a.line as f64)), ("trait".into(), s(t)), ("action".into(), s(level)), + ("released".into(), released.map_or(Json::Null, |r| s(&r))), ("why".into(), s(why))])))); } } + Ok((checked, None)) +} +/// `verify` as one document: the summary, or `failure`'s document +pub fn verify_doc(text: &str, eng: Engine) -> Json { verify(text, eng).unwrap_or_else(|(line, why)| failure(text, line, &why)) } +/// The document of a strand that does not verify at `line`: {ok: false, line, diverges: what differs}, or, for a field or line +/// kind this engine does not know, {ok: false, line, unsupported: the field's path, engine: this engine, strand_engine: the +/// header's `engine` (null if it names none)} +pub fn failure(text: &str, line: usize, why: &str) -> Json { + let mut v = vec![("ok".into(), Json::Bool(false)), ("line".into(), Json::Num(line as f64))]; + match unsupported_field(why) { + Some(f) => { v.push(("unsupported".into(), Json::Str(f.to_string()))); v.push(("engine".into(), Json::Str(engine()))); v.push(("strand_engine".into(), strand_engine(text))); } + None => v.push(("diverges".into(), Json::Str(why.to_string()))) } + Json::Obj(v) +} +/// The engine a strand's header names: its `engine` string, or null when the header is not JSON or has none +pub fn strand_engine(text: &str) -> Json { + json::parse(lines(text).into_iter().next().unwrap_or("")).ok().and_then(|h| h.get("engine").cloned()).filter(|e| matches!(e, Json::Str(_))).unwrap_or(Json::Null) +} +/// This binary's engine (the strand header's `engine`) +pub fn engine() -> String { format!("probbit {}", env!("CARGO_PKG_VERSION")) } + +/// Validate the stored chain and lifecycle before extending it. This is deliberately not +/// inference replay or authentication: verify still recomputes every event. In particular, a +/// checkpoint cannot reactivate a retired individual or hide an invalid earlier control. +pub fn check_chain(text: &str) -> Result { + let ls = lines(text); + let err = |line: usize, why: String| perr("strand", format!("line {line}: {why}")); + let r = Replay::open(ls.first().copied().unwrap_or("")).map_err(|(i, why)| err(i, why))?; + let mut status = Status::Active; + let mut n = 0u64; + for i in 1..ls.len() { + let j = json::parse(ls[i]).map_err(|e| err(i + 1, e.msg))?; + if persona::canon(&j) != ls[i] { return Err(err(i + 1, "not canonical JSON".into())); } + if j.get("prev").and_then(Json::as_str) != Some(persona::digest_of(ls[i - 1]).as_str()) { + return Err(err(i + 1, "prev is not the sha256 of the line before".into())); + } + if let Some(k) = unknown_key(&j) { return Err(err(i + 1, unsupported(k))); } + match kind(&j) { + Kind::Control => { + let f = |k: &str| j.get(k).and_then(Json::as_str).unwrap_or(""); + status = status.after(f("control")).map_err(|e| err(i + 1, e.msg))?; + let line = control_line(f("prev"), f("control"), f("by"), f("reason"), f("at")).map_err(|e| err(i + 1, e.msg))?; + if line != ls[i] { return Err(err(i + 1, "the control line differs".into())); } + } + Kind::Event => { + if status != Status::Active { return Err(err(i + 1, format!("event while {}", status.name()))); } + n += 1; + if j.get("n").and_then(Json::as_f64) != Some(n as f64) { return Err(err(i + 1, format!("n is not {n}"))); } + let Some(Json::Obj(_)) = j.get("inputs") else { return Err(err(i + 1, "no input object".into())) }; + for k in ["state", "stance"] { + if !j.get(k).and_then(Json::as_str).is_some_and(|s| s.len() == 71 && s.starts_with("sha256:") && s[7..].bytes().all(|c| c.is_ascii_hexdigit())) { + return Err(err(i + 1, format!("invalid {k} digest"))); + } + } + } + Kind::Rest => { + if status != Status::Active { return Err(err(i + 1, format!("rest line while {}", status.name()))); } + if !ls.get(i + 1).and_then(|x| json::parse(x).ok()).is_some_and(|x| matches!(kind(&x), Kind::Event)) { return Err(err(i + 1, "a rest line must be followed by its event line".into())); } + } + Kind::Checkpoint => { + if status != Status::Active { return Err(err(i + 1, format!("checkpoint while {}", status.name()))); } + // Full state validation plus links to the immediately preceding event; no inference. + let cp = Replay::open_at(ls[0], ls[i - 1], ls[i], i + 1).map_err(|(at, why)| err(at, why))?; + if cp.live.n != n { return Err(err(i + 1, "checkpoint event count differs".into())); } + // Keep the header persona/state check independent of the caller's state. + debug_assert_eq!(cp.live.p.digest, r.live.p.digest); + } + } + } + Ok(status) +} + +/// Continue a strand: `text` is the strand so far and `st` the individual after its last line -> the individual, ready for the +/// next event (no header to write). The header's persona must be `p` (the same digest); the run uses the header's document, the +/// key order the strand replays with. The lines before are not replayed here (`verify` does that). +pub fn resume(text: &str, p: &Persona, st: State, clock: Clock) -> Result { + let bad = |m: String| perr("strand", m); + if !text.ends_with('\n') { return Err(bad("its last line is incomplete (no newline at the end)".into())); } + check_chain(text)?; + let lines = lines(text); + let h = json::parse(lines.first().copied().unwrap_or("")).map_err(|e| bad(format!("the header is not JSON: {}", e.msg)))?; + if h.get("probbit_strand").and_then(Json::as_f64) != Some(FORMAT) { return Err(bad("not a probbit strand (format 1)".into())); } + let q = persona::build(h.get("document").unwrap_or(&Json::Null)).map_err(|e| bad(format!("the header's persona: {}: {}", e.path, e.msg)))?; + if q.digest != p.digest { return Err(bad(format!("it is another persona's ({} != {})", &q.digest[..19], &p.digest[..19]))); } + let last = lines[lines.len() - 1]; + // the control lines after the last event or checkpoint line (the anchor; the header without either) set the status: an + // event or a checkpoint is only ever written while active + let (mut a, mut tail) = (lines.len() - 1, vec![]); + while a > 0 { let j = json::parse(lines[a]).map_err(|e| bad(format!("line {} is not JSON: {}", a + 1, e.msg)))?; + if !matches!(kind(&j), Kind::Control) { break; } tail.push(j); a -= 1; } + let (n, want) = if a == 0 { (0, h.get("state").and_then(|s| s.get("digest")).and_then(Json::as_str).unwrap_or("").to_string()) } else { + let j = json::parse(lines[a]).map_err(|e| bad(format!("line {} is not JSON: {}", a + 1, e.msg)))?; + match kind(&j) { Kind::Checkpoint => (j.get("checkpoint").and_then(Json::as_f64).unwrap_or(0.0) as u64, j.get("state").and_then(|s| s.get("digest")).and_then(Json::as_str).unwrap_or("").to_string()), + _ => (j.get("n").and_then(Json::as_f64).unwrap_or(0.0) as u64, j.get("state").and_then(Json::as_str).unwrap_or("").to_string()) } }; + if st.digest != want { return Err(perr("state", format!("not the strand's last state ({} != {})", &st.digest[..st.digest.len().min(19)], &want[..want.len().min(19)]))); } + // RFC 0009: the expectation's memory, folded from every event line's inputs and logged deviation + let expect = q.expect.as_ref().map(|_| { let mut m = persona::ExpectMem::new(); + for l in lines.iter().skip(1) { if let Ok(j) = json::parse(l) { if matches!(kind(&j), Kind::Event) { let d = j.get("deviation"); + let lp = d.filter(|d| d.get("cold").is_none()).and_then(|d| d.get("pred")).and_then(Json::as_f64); + persona::expect_step(&q, &mut m, j.get("inputs").unwrap_or(&Json::Null), lp); } } } m }); + let mut lv = Live { p: q, st, clock, last: 0.0, prev: persona::digest_of(last), n, status: Status::Active, controls: 0, checkpoints: 0, rests: 0, expect, traj: None }; + for c in tail.iter().rev() { let what = c.get("control").and_then(Json::as_str).unwrap_or(""); + lv.status = lv.status.after(what).unwrap_or(Status::Retired); lv.st = lv.st.zero_credit(&lv.p); lv.controls += 1; } + Ok(lv) +} + +/// The audit of a recorded life (`probbit persona bound PERSONA --audit STRAND`): the strand replayed line by line; after every +/// event and control line, per learned trait the resting TV between the individual and the same individual with its learned +/// weights at zero, as a share of its epsilon (the room used). An exceedance is impossible by the theorem; the audit shows it +/// on the recorded life. +pub fn bound_audit(text: &str, b: &persona::bound::Bound, eng: Engine) -> Result<(Json, bool), (usize, String)> { + let lines = lines(text); + let mut r = Replay::open(lines.first().copied().unwrap_or(""))?; + let p = r.live.p.clone(); let (traits, cap) = persona::bound::learned_traits(&p).ok_or((1, "the strand's persona has no learning block: nothing to audit".to_string()))?; + let rest = b.rest_hours.unwrap_or_else(|| persona::bound::default_rest(&p)); let seed = r.live.st.seed; + let e = |x: InErr| (0usize, format!("{}: {}", x.path, x.msg)); + let st0 = persona::init(&p, Some(seed), true, eng); let p0 = persona::bound::rest_odds(&p, &st0, rest, eng).map_err(e)?; + let mut eps = vec![]; let mut rr = vec![]; + for (k, t) in traits.iter().enumerate() { let sc = persona::bound::scope(&p, b, t, rest, Some(&st0)); + let bs = persona::bound::bases(&p, &sc, &st0).ok_or((0, format!("{t}: more than {} enumerated history combinations: the audit needs an exact epsilon", persona::bound::MAX_HISTORY)))?; + eps.push(persona::bound::search(&p, k, &sc.coords, cap, &bs, &p0[k], rest, eng).map_err(e)?.0); rr.push((sc.d_r / 4.0).tanh()); } + let nt = traits.len(); let (mut maxr, mut at_n, mut cur, mut exceed) = (vec![0.0f64; nt], vec![0u64; nt], vec![0.0f64; nt], 0usize); + let (mut events, mut feedback, mut erasures) = (0u64, 0u64, 0u64); let mut last = persona::bound::learned_of(&r.live.st); + let measure = |st: &persona::State| -> Result, (usize, String)> { let a = persona::bound::rest_odds(&p, st, rest, eng).map_err(e)?; + let z = persona::bound::rest_odds(&p, &persona::bound::with_learned(&p, st, &persona::bound::learned_of(st).iter().map(|d| vec![0.0; d.len()]).collect::>()), rest, eng).map_err(e)?; + Ok(a.iter().zip(&z).map(|(x, y)| persona::bound::tv(x, y)).collect()) }; + for l in lines.iter().skip(1) { if l.trim().is_empty() { continue; } + let was_event = r.step(l, eng)?.is_some(); if was_event { events += 1; } else if l.contains(r#""control":"erase""#) { erasures += 1; } + let now = persona::bound::learned_of(&r.live.st); if now != last { if was_event { feedback += 1; } last = now; } + let tvs = measure(&r.live.st)?; + for k in 0..nt { cur[k] = tvs[k]; if eps[k] > 0.0 && tvs[k] / eps[k] > maxr[k] { maxr[k] = tvs[k] / eps[k]; at_n[k] = r.live.n; } if tvs[k] > eps[k] + rr[k] + 1e-6 { exceed += 1; } } } + let s = |x: &str| Json::Str(x.to_string()); + let rows: Vec = traits.iter().enumerate().map(|(k, t)| Json::Obj(vec![("trait".into(), s(t)), ("epsilon".into(), Json::Num(eps[k])), + ("room_used_max".into(), Json::Num(persona::r6(maxr[k]))), ("at_event".into(), Json::Num(at_n[k] as f64)), ("room_used_now".into(), Json::Num(persona::r6(if eps[k] > 0.0 { cur[k] / eps[k] } else { 0.0 }))), + ("distance_to_ceiling".into(), Json::Num(persona::r6(eps[k] - cur[k]))), ("tv_now".into(), Json::Num(persona::r6(cur[k])))])).collect(); + let ok = exceed == 0; + Ok((Json::Obj(vec![("persona".into(), s(&p.name)), ("digest".into(), s(&p.digest)), ("seed".into(), Json::Num(seed as f64)), ("rest_hours".into(), Json::Num(rest)), + ("events".into(), Json::Num(events as f64)), ("feedback_events".into(), Json::Num(feedback as f64)), ("erasures".into(), Json::Num(erasures as f64)), + ("traits".into(), Json::Arr(rows)), ("exceedances".into(), Json::Num(exceed as f64)), ("ok".into(), Json::Bool(ok))]), ok)) +} diff --git a/probbit-cli/src/sys.rs b/probbit-persona/src/sys.rs similarity index 98% rename from probbit-cli/src/sys.rs rename to probbit-persona/src/sys.rs index 1001905..816c67e 100644 --- a/probbit-cli/src/sys.rs +++ b/probbit-persona/src/sys.rs @@ -1,4 +1,6 @@ //! Process resource usage without crates: getrusage(2) via FFI on 64-bit Unix (macOS and Linux struct layouts); None elsewhere. +#[allow(unused_imports)] +use std::prelude::rust_2021::*; // 64-bit only: the layouts below assume 64-bit time_t / long (32-bit Linux or BSD would read garbage telemetry) #[cfg(all(unix, target_pointer_width = "64"))] mod imp { diff --git a/probbit-cli/src/yaml.rs b/probbit-persona/src/yaml.rs similarity index 99% rename from probbit-cli/src/yaml.rs rename to probbit-persona/src/yaml.rs index 81f7bf1..c7a3963 100644 --- a/probbit-cli/src/yaml.rs +++ b/probbit-persona/src/yaml.rs @@ -8,6 +8,8 @@ //! `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). +#[allow(unused_imports)] +use std::prelude::rust_2021::*; use crate::json::Json; pub struct YamlError { pub line: usize, pub msg: String } diff --git a/probbit-wasm/Cargo.toml b/probbit-wasm/Cargo.toml index 0f59dd1..3d6155d 100644 --- a/probbit-wasm/Cargo.toml +++ b/probbit-wasm/Cargo.toml @@ -15,3 +15,4 @@ crate-type = ["cdylib", "rlib"] probbit-core = { path = "../probbit-core" } probbit-ir = { path = "../probbit-ir" } probbit-decide = { path = "../probbit-decide" } +probbit-persona = { path = "../probbit-persona" } diff --git a/probbit-wasm/src/lib.rs b/probbit-wasm/src/lib.rs index c6d3263..deaf956 100644 --- a/probbit-wasm/src/lib.rs +++ b/probbit-wasm/src/lib.rs @@ -5,7 +5,7 @@ //! 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, persona.rs, yaml.rs and sys.rs, included below) run with +//! The code is the CLI's (the probbit-persona library: json, run, router, evaluate, persona, yaml and sys) 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 @@ -16,42 +16,9 @@ //! 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)] -#[path = "../../probbit-cli/src/json.rs"] -mod json; -#[allow(dead_code)] -#[path = "../../probbit-cli/src/run.rs"] -mod run; -#[allow(dead_code)] -#[path = "../../probbit-cli/src/router.rs"] -mod router; -#[allow(dead_code)] -#[path = "../../probbit-cli/src/evaluate.rs"] -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) {} - pub fn gate_seen(_: f64, _: f64, _: usize, _: usize) {} -} -use json::{num, str as jstr, InErr, Json}; +use probbit_persona::{evaluate, json, persona, router, run}; +use json::{str as jstr, InErr, Json}; use probbit_core::rt::Instant; -use router::item_bars; - -/// The telemetry fields of `--exact-ms` (as probbit-cli/src/main.rs). -fn exact_cap(xms: Option, reached: bool) -> Vec<(&'static str, Json)> { - xms.map_or(vec![], |x| vec![("exact_ms", num(x)), ("exact_ms_reached", Json::Bool(reached))]) -} -/// No configuration file in a browser (the CLI echoes probbit.json / $PROBBIT_CONFIG here). -fn config_echo() -> Vec<(&'static str, Json)> { vec![] } /// `probbit decide` on a router document (+ optional `flags`) -> (the decision JSON, the CLI's exit code). pub fn decide(input: &str) -> (String, i32) { call(input, "decide") } diff --git a/probbit-wasm/tests/no_threads.rs b/probbit-wasm/tests/no_threads.rs index 840082a..140cde7 100644 --- a/probbit-wasm/tests/no_threads.rs +++ b/probbit-wasm/tests/no_threads.rs @@ -3,7 +3,7 @@ //! sampled probbit-ir program and a sampled `evaluate` request. Only timings and `telemetry.threads` (1 vs 4) may differ. The //! CLI's own threads-1-vs-N equality is probbit-cli's `run_threads_and_chains_are_resource_controls`; this extends it to no threads. #[allow(dead_code)] -#[path = "../../probbit-cli/src/json.rs"] +#[path = "../../probbit-persona/src/json.rs"] mod json; use json::Json; diff --git a/probbit-wasm/tests/persona.rs b/probbit-wasm/tests/persona.rs index 7368e6d..dbad0b7 100644 --- a/probbit-wasm/tests/persona.rs +++ b/probbit-wasm/tests/persona.rs @@ -2,7 +2,7 @@ //! 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"] +#[path = "../../probbit-persona/src/json.rs"] mod json; use json::Json; From 9e65fa6b067211a092990da9c9a06aaaf7c80082 Mon Sep 17 00:00:00 2001 From: BitmapAsset Date: Mon, 26 Oct 2026 11:33:48 -0700 Subject: [PATCH 02/61] probbit-persona: the stance's deviation at 6 decimals, so a checkpoint line is the same on every libm The stance's `deviation` (`self`, `pred`, `input`, from `ln`) was the only number a strand line printed at full precision. A `nostd` build takes `ln` from the `libm` crate, which differed from the platform's libm by 2 ULP on one deviation, so the second checkpoint line of a device strand differed from the host's replay and the chain broke there (the state digest was equal). From engine 0.14.0 every number of the deviation is rounded to 6 decimals, as every other number of a stance and a state is. A strand whose header names an earlier engine keeps the full-precision formatting when it is replayed or continued, and `live verify` accepts its checkpoint when it equals the replay's at 6 decimals (the chain goes on from the logged bytes); the event line's receipt is checked exactly, as before. Strands without an expectation block are unchanged. A regression fixture recorded by 0.13.0 (an expectation block, 4 checkpoints) and a test: it verifies as recorded and with a deviation's last bits changed, diverges on a change at the 6th decimal, and a 0.14.0 life logs at most 6 decimals. --- CHANGELOG.md | 6 +++ docs/embedding.md | 6 +++ docs/persona.md | 12 +++++- probbit-cli/src/live.rs | 38 ++++++++++++++++++ .../strands/expectation-0.13.0.strand | 21 ++++++++++ probbit-persona/src/persona.rs | 8 ++-- probbit-persona/src/strand.rs | 40 ++++++++++++++++--- 7 files changed, 122 insertions(+), 9 deletions(-) create mode 100644 probbit-cli/tests/fixtures/strands/expectation-0.13.0.strand diff --git a/CHANGELOG.md b/CHANGELOG.md index f4c12e0..23bb868 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ All notable changes to this project are documented here. The format follows byte for byte as 0.13.0. probbit-core, probbit-ir, probbit-decide and probbit-persona gain the features `std` (default) and `nostd` (`no_std` + `alloc`, through the new `probbit-nostd` facade), checked on `xtensa-esp32s3-none-elf`; probbit-wasm depends on the crate instead of including the CLI's files. +- **Cross-platform parity of the deviation** (docs/persona.md section 2.17, docs/embedding.md): the stance's `deviation` + (`self`, `pred`, `input`) is rounded to 6 decimals like every other number of a stance, so a checkpoint line has the same + bytes on every libm (a `nostd` build's `libm` crate differed from the host's by 2 ULP, which broke the chain at the second + checkpoint). Gated on the strand header's engine: a strand written before 0.14.0 keeps its full-precision deviation, and + `live verify` accepts its checkpoint when it equals the replay's at 6 decimals; strands without an expectation block are + unchanged. ## 0.13.0 - 2026-10-26 diff --git a/docs/embedding.md b/docs/embedding.md index ec80365..7dbf146 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -84,6 +84,12 @@ names the earliest line that differs (a changed, removed or reordered line, or a not reproduce), or the earliest field this engine does not know (`unsupported`: not verified, and not a divergence). `strand::Replay` steps a strand line by line, and `strand::resume(text, &p, st, clock)` continues one from its last state. +A `nostd` build takes its transcendentals (`ln`, `exp`) from the `libm` crate, which can differ from the host's libm in the +last bits (2 ULP measured on one deviation). Every number a strand line carries is rounded (6 decimals, or 5 significant +digits for the deviation receipt), so the device's strand and the host's replay have the same bytes; from engine 0.14.0 this +includes the stance's `deviation` on checkpoint lines (`strand::DEV_R6_SINCE`). A strand whose header names an earlier engine +keeps its full-precision deviation, and verify accepts it when it equals the replay at 6 decimals. + ## The host hooks `probbit_persona::hooks` lets a host see the decision pipeline: `set_monitor(tier, gate_seen)` (the CLI's `--top` view) and diff --git a/docs/persona.md b/docs/persona.md index 7fcbbb8..930a37e 100644 --- a/docs/persona.md +++ b/docs/persona.md @@ -955,6 +955,14 @@ line logs excludes `deviation`. Bound (by construction): per variable, `pred_i < the block verifies from its header (`--from-checkpoint` refuses: the memory reads every event). The deviation is absent (`null`) when a program is not solved by the exact tier. +Every number of the stance's `deviation` is rounded to 6 decimals (the rule every other number of a stance and a state +follows), so a checkpoint line, which carries the stance, has the same bytes on every platform: `self`, `pred` and `input` come +from `ln`, whose last bits differ between libm implementations (macOS, glibc, the `libm` crate of a `nostd` build differ by a +few ULP). Engines before 0.14.0 wrote `self`, `pred` and `input` at full precision; a strand whose header names such an engine +keeps that formatting when it is replayed or continued, and `live verify` accepts its checkpoint line when it equals the +replay's at 6 decimals (the chain goes on from the logged bytes), so a strand written on one platform verifies on another. +The event line's receipt (5 significant digits) is checked exactly, as before. + **Trajectory.** p_beta, proportional to p^beta, of the event's program at the k betas, each solved by the exact tier on `Model::scaled(beta)`: no sampler, a pure function of the program. It is derived and never logged: `probbit monitor` shows a `TRAJECTORY` row (mean marginal entropy per step) and its JSON frame a `trajectory` key; `probbit persona @@ -1535,7 +1543,9 @@ event line: `{"checkpoint":n,"prev":"sha256:...","stance":{...},"state":{...}}`, and the whole state after it, chained like the others. A full `verify` replays from the header and checks every checkpoint against the replay (its `"checkpoints": N` is in the summary); `verify --from-checkpoint` reads the header, checks the last checkpoint against the event line before it (prev, the stance digest, the state digest, the state read as any state is) and -replays only what follows (`"from_checkpoint": n`); the lines before it are trusted, so run a full `verify` to check them. +replays only what follows (`"from_checkpoint": n`); the lines before it are trusted, so run a full `verify` to check them. Every +number a checkpoint line carries is rounded (6 decimals; genes 4), so the line is the same on every platform's libm; for the +stance's `deviation` this holds from engine 0.14.0 on (section 2.17). **Senses in live** (section 2.11). An event carries `senses` as `persona turn` does; the strand logs each reading as used, with its source, and `verify` recomputes the normals from the logged readings: its summary adds `"normals": {"": {"mean", "n", diff --git a/probbit-cli/src/live.rs b/probbit-cli/src/live.rs index 8d4a83a..524b86c 100644 --- a/probbit-cli/src/live.rs +++ b/probbit-cli/src/live.rs @@ -779,6 +779,44 @@ mod tests { for i in from.max(1)..ls.len() { let mut j = json::parse(&ls[i]).unwrap(); put(&mut j, &["prev"], Json::Str(persona::digest_of(&ls[i - 1]))); ls[i] = persona::canon(&j); } ls.join("\n") + "\n" } + /// Cross-platform parity of the deviation (0.14.0): a strand of 0.13.0 with an expectation block and checkpoint lines (the + /// stance's `deviation` at full precision, as that engine wrote it) verifies as recorded, and verifies with a deviation's last + /// bits changed as another platform's libm gives them (the chain re-made after it); a change at the 6th decimal still + /// diverges. From 0.14.0 every number of the deviation is at 6 decimals, so the line is the same on every libm. The fixture + /// was recorded by the 0.13.0 release build (Linux x86_64): `probbit live` on this module's DOC with `"expectation": {}`, + /// `--seed 2 --clock fixed --checkpoint-every 4`, 16 events (3 time-only, then `ev(3..16)`). + #[test] + fn the_deviation_is_the_same_on_every_libm() { + assert_eq!(engine_version("probbit 0.13.0"), Some((0, 13, 0))); assert_eq!(engine_version("probbit 0.14.0-dev"), Some((0, 14, 0))); + assert_eq!(engine_version("probbit test"), None); assert_eq!(engine_version("probbit 0.14"), None); assert_eq!(engine_version("other 0.13.0"), None); + const F: &str = include_str!("../tests/fixtures/strands/expectation-0.13.0.strand"); + let ok = verify(F, &run).unwrap(); assert_eq!((ok.get("engine"), ok.get("checkpoints")), (Some(&Json::Str("probbit 0.13.0".into())), Some(&Json::Num(4.0)))); + let ls: Vec = F.lines().map(str::to_string).collect(); + let dev = |l: &str, k: &str| json::parse(l).unwrap().get("stance").and_then(|s| s.get("deviation")).and_then(|d| d.get(k)).and_then(Json::as_f64); + let at = ls.iter().position(|l| dev(l, "pred").is_some_and(|x| persona::r6(x) != x)).expect("a checkpoint with a full-precision deviation"); + let pred = dev(&ls[at], "pred").unwrap(); + let with = |x: f64| { let mut j = json::parse(&ls[at]).unwrap(); put(&mut j, &["stance", "deviation", "pred"], Json::Num(x)); let mut tt = ls.clone(); tt[at] = persona::canon(&j); rechain(tt, at) }; + // 2 ULP (the `libm` crate of a nostd build against the platform's libm): verifies + let other = with(f64::from_bits(pred.to_bits() + 2)); assert_ne!(other, F); + assert_eq!(verify(&other, &run).map(|d| d.get("events").cloned()), Ok(ok.get("events").cloned())); + // a change the 6 decimals show: diverges at that line + assert_eq!(verify(&with(pred + 1e-5), &run).unwrap_err(), (at + 1, "the checkpoint's state or stance differs from the replay".to_string())); + // from 0.14.0: the same life logs every number of the deviation at 6 decimals and verifies; 0.13.0 logged more + let h = json::parse(&ls[0]).unwrap(); let doc = h.get("document").unwrap().clone(); let p = persona::build(&doc).unwrap(); + let evs: Vec = ls[1..].iter().filter_map(|l| json::parse(l).ok()).filter(|j| matches!(kind(j), Kind::Event)).map(|j| j.get("inputs").unwrap().clone()).collect(); + let life = |engine: &str| { let st = State::read(&p, h.get("state").unwrap()).unwrap(); let (mut lv, head) = Live::start(p.clone(), &doc, st, Clock::Fixed, engine); let mut text = format!("{head}\n"); + for e in &evs { let (s, l) = lv.event(e, &run).unwrap(); text += &l; text.push('\n'); if lv.n % 4 == 0 { text += &lv.checkpoint(&s); text.push('\n'); } } text }; + let (old, new) = (life("probbit 0.13.0"), life("probbit 0.14.0")); + assert_eq!(old, F, "0.13.0's bytes, as recorded"); + let nums = |t: &str| t.lines().filter_map(|l| json::parse(l).ok()?.get("stance")?.get("deviation").cloned()).flat_map(|d| ["self", "pred", "input", "z"].map(|k| d.get(k).and_then(Json::as_f64))).flatten().collect::>(); + let n = nums(&new); assert_eq!(n.len(), 16); assert!(n.iter().all(|x| persona::r6(*x) == *x), "{n:?}"); assert!(nums(&old).iter().any(|x| persona::r6(*x) != *x)); + assert_eq!(verify(&new, &run).map(|d| d.get("checkpoints").cloned()), Ok(Some(Json::Num(4.0)))); + // a 0.14.0 checkpoint is checked byte for byte: a rounded number's last bits changed diverge + let ln: Vec = new.lines().map(str::to_string).collect(); let c = ln.iter().position(|l| dev(l, "pred").is_some_and(|x| x > 0.0)).unwrap(); + let mut j = json::parse(&ln[c]).unwrap(); put(&mut j, &["stance", "deviation", "pred"], Json::Num(f64::from_bits(dev(&ln[c], "pred").unwrap().to_bits() + 2))); let mut tt = ln.clone(); tt[c] = persona::canon(&j); + assert_eq!(verify(&rechain(tt, c), &run).unwrap_err().0, c + 1); + } + /// docs/persona.md §5.7, "Unsupported": a header key, a persona field, a state field, a line kind or a line field this engine /// does not know (a newer engine's strand, its chain whole) is `unsupported by this engine: FIELD` at the line that has it, /// never a divergence; `verify_doc` (the MCP tool's document) is {ok, line, unsupported, engine, strand_engine}. A tampered diff --git a/probbit-cli/tests/fixtures/strands/expectation-0.13.0.strand b/probbit-cli/tests/fixtures/strands/expectation-0.13.0.strand new file mode 100644 index 0000000..67577d2 --- /dev/null +++ b/probbit-cli/tests/fixtures/strands/expectation-0.13.0.strand @@ -0,0 +1,21 @@ +{"probbit_strand":1,"engine":"probbit 0.13.0","persona":{"name":"L","version":"1","digest":"sha256:96c546264b25be2c315f32ca9530b1c69d1e7bc574568130d44b971d9abd1f9a"},"seed":2,"state":{"credit":{"humour":[0,0,0],"verbosity":[0,0,0]},"digest":"sha256:842126d9cd264d79214b3ba0c3c591a6b00ec1b32daf5f43ea1383d5bad9de62","genes":{"react":{},"shift":{"verbosity":0.5685}},"history":{},"learned":{"humour":[0,0,0],"verbosity":[0,0,0]},"mood":{"valence":[0,0,0]},"persona":{"digest":"sha256:96c546264b25be2c315f32ca9530b1c69d1e7bc574568130d44b971d9abd1f9a","name":"L","version":"1"},"prev":{"humour":"light","valence":"down","verbosity":"short"},"probbit_persona_state":1,"rest":{"humour":"playful","valence":"up","verbosity":"full"},"seed":2,"turn":0},"document":{"probbit_persona":1,"identity":{"name":"L","version":"1","seed":3},"traits":[{"id":"verbosity","levels":["terse","short","full"],"logw":[0.2,0.5,0.1],"spread":0.4},{"id":"humour","levels":["none","light","playful"],"logw":[0,0.3,0.1]}],"moods":[{"id":"valence","levels":["down","even","up"],"inertia":0.5,"half_life_hours":6}],"couplings":[{"vars":["valence","humour"],"align":0.8}],"inputs":[{"id":"praise","kind":"flag","effects":{"valence":0.6}},{"id":"criticism","kind":"flag","effects":{"valence":-0.6}},{"id":"loss","kind":"flag","effects":{"valence":-1}}],"habits":[{"id":"no_jokes_on_loss","when":{"loss":true},"then":{"humour":["none"]}}],"learning":{"from":["praise","criticism"],"traits":["verbosity","humour"],"rate":0.5,"step_cap":0.2,"total_cap":0.6},"expectation":{}}} +{"deviation":{"cold":true,"input":0,"pred":0,"self":0},"inputs":{"elapsed_hours":1},"n":1,"prev":"sha256:17866f9c22b18e0d8ec755c127928a634af02a20d10ad199b02bf374d8fe2722","stance":"sha256:6acddf01e3781f322a1dbcc242989cc830284431109bd4f4081a0d0bd26b5b4e","state":"sha256:4cfd5a8370ddbd7104e4b456b527a6232ad0156918b9f4944cb6cdca6af3c3da"} +{"deviation":{"cold":true,"input":0,"pred":0,"self":0},"inputs":{"elapsed_hours":1},"n":2,"prev":"sha256:22d36143ae3c4d92352f6048cdf6f4470ea11efdd8d2e9796635dab2621814f2","stance":"sha256:45277161705b0718c8de1d2a980ccef9ded38a8b47ae14322b6ee980c63a7716","state":"sha256:7bd3f1e043baac45b63a56dc93c0b00b21c404670f26f3a3cbdc0d0d1c58c8f5"} +{"deviation":{"cold":true,"input":0,"pred":0,"self":0},"inputs":{"elapsed_hours":1},"n":3,"prev":"sha256:0cc4c969c1f834656fea64cf997e50c8c9a0f0ef1a34ed1d831088fd4f613181","stance":"sha256:42fbde5df0c71ad7b527bb25c5d3e6507ef3a3a960d46a8bf5fa7e4a45180bc3","state":"sha256:07c85a68f6a69b4526135275a5426e55c85266875598d6f9c0c335c4b40a0a80"} +{"deviation":{"input":0,"pred":0.10777,"self":0.10777},"inputs":{"elapsed_hours":0.25,"loss":false,"praise":true},"n":4,"prev":"sha256:ca88cb5964207324c2b6d37f0127e2c61b1cba61f4dab7ed27812e9f734c7694","stance":"sha256:2a2615e03ff2001936b3b786937b6496cdbf0ae308384f734f96d11159256596","state":"sha256:9607b2f710e0c2305c9ca3bfe2b63d4dde5b06df758af01083d5404287ad2eef"} +{"checkpoint":4,"prev":"sha256:37a2f283ba7130019300195d7b1670451a47926eab18b8b456ed4baa5edc64f1","stance":{"deviation":{"cold":false,"input":0,"pred":0.10777474788205145,"registers":false,"self":0.10777474788205145,"z":0.511106},"engine":{"program":"sha256:9f267c0e70c1af8bb5ec68e89846e3d0c3c1b63910336de1d72aeaea186a3f47","tier":"enumerate","verdict":"exact"},"escalate":null,"habits":{"active":[],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"criticism":false,"loss":false,"praise":true},"line":"Stance: neutral.","line_tokens":4,"mood":{"valence":{"level":"up","odds":{"down":0.222345,"even":0.275014,"up":0.502641},"p":0.502641,"released":true}},"persona":{"digest":"sha256:96c546264b25be2c315f32ca9530b1c69d1e7bc574568130d44b971d9abd1f9a","name":"L","version":"1"},"probbit_persona_turn":1,"seed":2,"stance":{"humour":{"level":"playful","odds":{"light":0.289096,"none":0.229186,"playful":0.481718},"p":0.481718,"released":true},"verbosity":{"level":"full","odds":{"full":0.543875,"short":0.310487,"terse":0.145638},"p":0.543875,"released":true}},"state_digest":"sha256:9607b2f710e0c2305c9ca3bfe2b63d4dde5b06df758af01083d5404287ad2eef","status":"ok","turn":3,"unsure":[],"why":"praise -> valence up"},"state":{"credit":{"humour":[-0.229186,-0.289096,0.518282],"verbosity":[-0.145638,-0.310487,0.456125]},"digest":"sha256:9607b2f710e0c2305c9ca3bfe2b63d4dde5b06df758af01083d5404287ad2eef","genes":{"react":{},"shift":{"verbosity":0.5685}},"history":{},"learned":{"humour":[-0.113335,-0.129232,0.242567],"verbosity":[-0.056369,-0.167855,0.224223]},"mood":{"valence":[-0.3,0,0.3]},"persona":{"digest":"sha256:96c546264b25be2c315f32ca9530b1c69d1e7bc574568130d44b971d9abd1f9a","name":"L","version":"1"},"prev":{"humour":"playful","valence":"up","verbosity":"full"},"probbit_persona_state":1,"rest":{"humour":"playful","valence":"up","verbosity":"full"},"seed":2,"turn":4}} +{"deviation":{"input":0.22967,"pred":2.0024,"self":1.8805},"inputs":{"elapsed_hours":0.25,"loss":true,"praise":false},"n":5,"prev":"sha256:a7be4fff27635b9333c71d694b54e28e858b83549a119d04f1ae15fd8a293912","stance":"sha256:6402c65a89ca1efd621fdd732fc30a4dc4426e60aaeedff7df4603691d5ebe77","state":"sha256:0614b592f101e7892f26f2b6059907d0b4e5c247a18c491d96afc1349479d12e"} +{"deviation":{"input":1.7778,"pred":0.12679,"self":0.051762},"inputs":{"elapsed_hours":0.25,"loss":false,"praise":true},"n":6,"prev":"sha256:4d1d4b7818f5874e543bd35a68883ef355e5de87eff6db21304540289d6c77f6","stance":"sha256:8020f08e05173396ff7882db0a87a1a5a4242cc11bfa76e637052acbb5dda971","state":"sha256:08b0be637d11ae09299e3ab94a4e1070bef3d604723998603910cb4e90f88a8c"} +{"deviation":{"input":0.57812,"pred":0.0213,"self":0},"inputs":{"elapsed_hours":9.123457,"loss":false,"praise":false},"n":7,"prev":"sha256:c96b3467bfae90b6ff1dee4a7ce74a260cee525ccd03305927ef076f060113eb","stance":"sha256:07a87eea3e6014941848e511061226e03b9660e7bec6a0d52a6bb550eb9c07e2","state":"sha256:6f5fd7fbd6dfa810e3b5bd7aee85ee2200a54ce7efccbf5d786198cddab8da87"} +{"deviation":{"input":1.3736,"pred":0.13136,"self":0.095029},"inputs":{"elapsed_hours":0.25,"loss":false,"praise":true},"n":8,"prev":"sha256:400dc144d5fbb3f55ab9acbf860a0b783a055167abaf9b43446a16b01381585b","stance":"sha256:be27e04d9feaed3c06bcfb85c6a5c02744fc47b6593bdffb0452ad3fd991ec2f","state":"sha256:cb4be3d3b830d6d7489f15001471ce2b1a20967477fc8eff0fec6bb04f2b2f50"} +{"checkpoint":8,"prev":"sha256:bba12ff31a838811c32cb2e61fae1ce2372d3ee4bd400be2aab2249045ab4505","stance":{"deviation":{"cold":false,"input":1.3735706067334614,"pred":0.13135632965625216,"registers":false,"self":0.09502931736614843,"z":0.387011},"engine":{"program":"sha256:aca27aa8be05732cfe36859133b9cb883d17175e071b8e155802495e05fde57b","tier":"enumerate","verdict":"exact"},"escalate":null,"habits":{"active":[],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"criticism":false,"loss":false,"praise":true},"line":"Stance: neutral.","line_tokens":4,"mood":{"valence":{"level":"up","odds":{"down":0.195949,"even":0.266716,"up":0.537336},"p":0.537336,"released":true}},"persona":{"digest":"sha256:96c546264b25be2c315f32ca9530b1c69d1e7bc574568130d44b971d9abd1f9a","name":"L","version":"1"},"probbit_persona_turn":1,"seed":2,"stance":{"humour":{"level":"playful","odds":{"light":0.239878,"none":0.191579,"playful":0.568544},"p":0.568544,"released":true},"verbosity":{"level":"full","odds":{"full":0.684434,"short":0.204712,"terse":0.110854},"p":0.684434,"released":true}},"state_digest":"sha256:cb4be3d3b830d6d7489f15001471ce2b1a20967477fc8eff0fec6bb04f2b2f50","status":"ok","turn":7,"unsure":[],"why":"praise -> valence up"},"state":{"credit":{"humour":[-0.191579,-0.239878,0.431456],"verbosity":[-0.110854,-0.204712,0.315566]},"digest":"sha256:cb4be3d3b830d6d7489f15001471ce2b1a20967477fc8eff0fec6bb04f2b2f50","genes":{"react":{},"shift":{"verbosity":0.5685}},"history":{},"learned":{"humour":[-0.220614,-0.249506,0.47012],"verbosity":[-0.183374,-0.438489,0.6]},"mood":{"valence":[-0.310828,0,0.310828]},"persona":{"digest":"sha256:96c546264b25be2c315f32ca9530b1c69d1e7bc574568130d44b971d9abd1f9a","name":"L","version":"1"},"prev":{"humour":"playful","valence":"up","verbosity":"full"},"probbit_persona_state":1,"rest":{"humour":"playful","valence":"up","verbosity":"full"},"seed":2,"turn":8}} +{"deviation":{"input":0.60225,"pred":0.015017,"self":0},"inputs":{"elapsed_hours":0.25,"loss":false,"praise":false},"n":9,"prev":"sha256:ac235dacafcdbba63ee172d35aefcdc5b8efc6e3b61e04918e4ceb39a52d0f98","stance":"sha256:f839a64cd9fa80e3f6cbfc39920496f5ae9f871aa6c3e8c8694aa84b04f9d625","state":"sha256:8dcf366c0c785d1e892675d1f4b2b38c31a911c70060a800d5abe9b58e59e41a"} +{"deviation":{"input":0.51509,"pred":1.641,"self":1.8939},"inputs":{"elapsed_hours":0.25,"loss":true,"praise":true},"n":10,"prev":"sha256:ca04ae8648102e924b4aab055dadb9260c5df8840a325444abbcb89c24bfe29b","stance":"sha256:22e3ae1f0ead1201359a0e83ef3b15d2760222af47d0858c62b55a422684cbce","state":"sha256:0c16d1544ac56c8e76381347e4732be56ef64640df26ba7a401ab2b56f93d61b"} +{"deviation":{"input":0.61788,"pred":0.051763,"self":0},"inputs":{"elapsed_hours":0.25,"loss":false,"praise":false},"n":11,"prev":"sha256:52c8347a2004f120e26e55969cfdbdab2489006d5ba6ccaddc014d174c9b1c02","stance":"sha256:f180db410c600b14fe14e7d1bfb34195fe46c8dc74b4886e505a5c73ff8a9762","state":"sha256:726bccb5d357c15f1f8f08883671734165f25b0560613c5325e4dbdfbeac4354"} +{"deviation":{"input":1.3973,"pred":0.14056,"self":0.046494},"inputs":{"elapsed_hours":0.25,"loss":false,"praise":true},"n":12,"prev":"sha256:180ddca6df7674126b2893b4ede00588b5277ae0aebe778424cf3851bf9efc9a","stance":"sha256:8ddea6f3fa884618de665c51e76901d5556def6e65546a14adbe3aa39b16ad93","state":"sha256:bc8376242453194efa4b048c5e9c92ea70d1017a573cf57ce75aea07f60fc4e2"} +{"checkpoint":12,"prev":"sha256:51a2db74aaaa2c57a85895b43908245f63ecab869286034a5f49612f73d2eb3f","stance":{"deviation":{"cold":false,"input":1.3972853268547112,"pred":0.1405603213908217,"registers":false,"self":0.04649365680031395,"z":0.300164},"engine":{"program":"sha256:2985364dce1ce6ea53b88cf065fc5eaa72a1a5b9a3b6f07c0bb95db2061e00fb","tier":"enumerate","verdict":"exact"},"escalate":null,"habits":{"active":[],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"criticism":false,"loss":false,"praise":true},"line":"Stance: neutral.","line_tokens":4,"mood":{"valence":{"level":"up","odds":{"down":0.183957,"even":0.263718,"up":0.552325},"p":0.552325,"released":true}},"persona":{"digest":"sha256:96c546264b25be2c315f32ca9530b1c69d1e7bc574568130d44b971d9abd1f9a","name":"L","version":"1"},"probbit_persona_turn":1,"seed":2,"stance":{"humour":{"level":"playful","odds":{"light":0.195009,"none":0.162072,"playful":0.642919},"p":0.642919,"released":true},"verbosity":{"level":"full","odds":{"full":0.714444,"short":0.181818,"terse":0.103738},"p":0.714444,"released":true}},"state_digest":"sha256:bc8376242453194efa4b048c5e9c92ea70d1017a573cf57ce75aea07f60fc4e2","status":"ok","turn":11,"unsure":[],"why":"praise -> valence up"},"state":{"credit":{"humour":[-0.162072,-0.195009,0.357081],"verbosity":[-0.103738,-0.181818,0.285556]},"digest":"sha256:bc8376242453194efa4b048c5e9c92ea70d1017a573cf57ce75aea07f60fc4e2","genes":{"react":{},"shift":{"verbosity":0.5685}},"history":{},"learned":{"humour":[-0.419121,-0.4668,0.6],"verbosity":[-0.292633,-0.6,0.6]},"mood":{"valence":[-0.270114,0,0.270114]},"persona":{"digest":"sha256:96c546264b25be2c315f32ca9530b1c69d1e7bc574568130d44b971d9abd1f9a","name":"L","version":"1"},"prev":{"humour":"playful","valence":"up","verbosity":"full"},"probbit_persona_state":1,"rest":{"humour":"playful","valence":"up","verbosity":"full"},"seed":2,"turn":12}} +{"deviation":{"input":0.62882,"pred":0.040851,"self":0},"inputs":{"elapsed_hours":0.25,"loss":false,"praise":false},"n":13,"prev":"sha256:d9b2125e825dcaf38fd6daa448e371de255da63b2c0045c20589f30c16b614ec","stance":"sha256:e5a28107a8108df44c20ae64ea49c247964aa0ab07f7b4cc399c755ca319b280","state":"sha256:abd800be5b71649c529b9a27f3f90793cb87763f520365db26a208d7b59e09ec"} +{"deviation":{"input":1.2527,"pred":0.11353,"self":0.043033},"inputs":{"elapsed_hours":9.123457,"loss":false,"praise":true},"n":14,"prev":"sha256:8c1f4de3b06e66a10c460bcf8b355565e85675469602968d6a999b38d503d6f4","stance":"sha256:5a0abdc05fe1dd6e5eea940557f6b7dd85f998be71128de950867c166caa1125","state":"sha256:0d839e3c6b8b82aca622adbebb16de595a4829de19bf3655e0ed8fa72e64bf8e"} +{"deviation":{"input":2.7758,"pred":1.9201,"self":2.47},"inputs":{"elapsed_hours":0.25,"loss":true,"praise":false},"n":15,"prev":"sha256:091034e07fcd7807b9020ab0dcd767d620dfa5d6dadd349f65f67476c9e93013","stance":"sha256:ac229573810eacb2a384f798d5995b371d13a6bf1d04deaa55e808f4d1fbaf8a","state":"sha256:2f6570a07f644c65dff98de40cf9614463755fd415253a1a4e43c41434099ecd"} +{"deviation":{"input":1.1568,"pred":0.14248,"self":0.036296},"inputs":{"elapsed_hours":0.25,"loss":false,"praise":true},"n":16,"prev":"sha256:0faa4b1c34a0cd5bfa5a5dabce524f4359e68f2f4420b39624e1ceddc201817c","stance":"sha256:0442bcfb7b2bd2673216987104081019c76e8f210074830345830935e8c0d078","state":"sha256:6e8fbc86bc87e19a8e979989bba592a4d308ad8be995bb49acb8b2091871d8ae"} +{"checkpoint":16,"prev":"sha256:0d6388726e1551aba2afcaa0532dbbec9d5ec1effd91d9ea25d4b03cefc75fd0","stance":{"deviation":{"cold":false,"input":1.1568247512105128,"pred":0.14248459408783548,"registers":false,"self":0.036296270796392,"z":0.196578},"engine":{"program":"sha256:ee852a1d68d245523506a2405355ae7e5a9c5925f15f4ff306fce4c8b12e20ac","tier":"enumerate","verdict":"exact"},"escalate":null,"habits":{"active":[],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"criticism":false,"loss":false,"praise":true},"line":"Stance: neutral.","line_tokens":4,"mood":{"valence":{"level":"up","odds":{"down":0.213779,"even":0.274449,"up":0.511773},"p":0.511773,"released":true}},"persona":{"digest":"sha256:96c546264b25be2c315f32ca9530b1c69d1e7bc574568130d44b971d9abd1f9a","name":"L","version":"1"},"probbit_persona_turn":1,"seed":2,"stance":{"humour":{"level":"playful","odds":{"light":0.18758,"none":0.168979,"playful":0.643441},"p":0.643441,"released":true},"verbosity":{"level":"full","odds":{"full":0.721661,"short":0.183654,"terse":0.094684},"p":0.721661,"released":true}},"state_digest":"sha256:6e8fbc86bc87e19a8e979989bba592a4d308ad8be995bb49acb8b2091871d8ae","status":"ok","turn":15,"unsure":[],"why":"praise -> valence up"},"state":{"credit":{"humour":[-0.168979,-0.18758,0.356559],"verbosity":[-0.094684,-0.183654,0.278339]},"digest":"sha256:6e8fbc86bc87e19a8e979989bba592a4d308ad8be995bb49acb8b2091871d8ae","genes":{"react":{},"shift":{"verbosity":0.5685}},"history":{},"learned":{"humour":[-0.508466,-0.566951,0.6],"verbosity":[-0.394009,-0.6,0.6]},"mood":{"valence":[-0.133304,0,0.133304]},"persona":{"digest":"sha256:96c546264b25be2c315f32ca9530b1c69d1e7bc574568130d44b971d9abd1f9a","name":"L","version":"1"},"prev":{"humour":"playful","valence":"up","verbosity":"full"},"probbit_persona_state":1,"rest":{"humour":"playful","valence":"up","verbosity":"full"},"seed":2,"turn":16}} diff --git a/probbit-persona/src/persona.rs b/probbit-persona/src/persona.rs index 7f22ffa..81d24fb 100644 --- a/probbit-persona/src/persona.rs +++ b/probbit-persona/src/persona.rs @@ -3913,9 +3913,11 @@ pub fn expect_step(p: &Persona, mem: &mut ExpectMem, raw: &Json, logged_pred: Op mem.mean += a * dx; mem.var = (1.0 - a) * (mem.var + a * dx * dx); mem.n += 1; } } impl Deviation { - /// The stance's `deviation` (full precision) - pub fn to_json(&self) -> Json { - Json::Obj(vec![("self".into(), Json::Num(self.s_self)), ("pred".into(), Json::Num(self.pred)), ("input".into(), Json::Num(self.input)), ("cold".into(), Json::Bool(self.cold)), + /// The stance's `deviation`: every number at 6 decimals (the canonical number rule), so a checkpoint line carries the same + /// bytes on every platform's libm; `full`: self, pred and input at full precision, as engines before 0.14.0 wrote them + pub fn to_json(&self, full: bool) -> Json { + let n = |x: f64| Json::Num(if full { x } else { r6(x) }); + Json::Obj(vec![("self".into(), n(self.s_self)), ("pred".into(), n(self.pred)), ("input".into(), n(self.input)), ("cold".into(), Json::Bool(self.cold)), ("z".into(), self.z.map_or(Json::Null, |z| Json::Num(r6(z)))), ("registers".into(), Json::Bool(self.z.is_some_and(|z| z >= DEV_Z)))]) } /// The strand line's receipt: self, pred and input at 5 significant digits, `cold: true` while cold diff --git a/probbit-persona/src/strand.rs b/probbit-persona/src/strand.rs index 319d69c..dc76539 100644 --- a/probbit-persona/src/strand.rs +++ b/probbit-persona/src/strand.rs @@ -52,7 +52,30 @@ pub const CHECKPOINT_EVERY: u64 = 1000; /// A live individual: the persona, the current state, the clock, the strand's chain and its status (control lines) pub struct Live { pub p: Persona, pub st: State, clock: Clock, last: f64, prev: String, pub n: u64, pub status: Status, pub controls: u64, pub checkpoints: u64, pub rests: u64, /// RFC 0009: the expectation's memory (a fold of the logged events) and the last event's exact trajectory (derived, never logged) - pub expect: Option, pub traj: Option } + pub expect: Option, pub traj: Option, + /// The strand's engine is before 0.14.0 (DEV_R6_SINCE): the stance's `deviation` keeps full precision, as that engine wrote it + pub dev_full: bool } +/// The engine from which the stance's `deviation` (on checkpoint lines) is rounded to 6 decimals: a transcendental's last bits +/// differ between platforms' libm (macOS, glibc, the `libm` crate of a `nostd` build), and 6 decimals make the line's bytes, +/// and so the chain, the same on all of them +pub const DEV_R6_SINCE: (u64, u64, u64) = (0, 14, 0); +/// A strand header's engine `probbit X.Y.Z` (a `-pre` or `+build` suffix ignored) -> (X, Y, Z); None for any other name +pub fn engine_version(engine: &str) -> Option<(u64, u64, u64)> { + let v = engine.strip_prefix("probbit ")?.split(['-', '+']).next()?; + let mut it = v.split('.').map(|x| x.parse::().ok()); + let r = (it.next()??, it.next()??, it.next()??); + if it.next().is_some() { None } else { Some(r) } +} +/// A line with its stance's `deviation` self, pred and input at 6 decimals, as canonical JSON (None when it is not JSON): a +/// checkpoint of a strand before 0.14.0 verifies when it is the replay's at the 6 decimals 0.14.0 writes (the libm's last bits +/// set aside); the stance digest excludes `deviation` and the event line's receipt is checked exactly +fn dev_r6(l: &str) -> Option { + fn field<'a>(j: &'a mut Json, k: &str) -> Option<&'a mut Json> { match j { Json::Obj(v) => v.iter_mut().find(|(x, _)| x == k).map(|(_, y)| y), _ => None } } + let mut j = json::parse(l).ok()?; + if let Some(d) = field(&mut j, "stance").and_then(|s| field(s, "deviation")) { + for k in ["self", "pred", "input"] { if let Some(Json::Num(x)) = field(d, k) { *x = persona::r6(*x); } } } + Some(persona::canon(&j)) +} /// A document with its keys sorted at every level (`persona::text` of it is its canonical JSON) fn sorted(j: &Json) -> Json { match j { Json::Obj(v) => { let mut o: Vec<(String, Json)> = v.iter().map(|(k, x)| (k.clone(), sorted(x))).collect(); o.sort_by(|a, b| a.0.cmp(&b.0)); Json::Obj(o) } @@ -73,7 +96,8 @@ impl Live { ("seed".into(), Json::Num(st.seed as f64)), ("state".into(), sorted(&st.to_json(&p))), ("document".into(), doc.clone())])); let prev = persona::digest_of(&header); let expect = p.expect.as_ref().map(|_| persona::ExpectMem::new()); - (Live { p, st, clock, last: 0.0, prev, n: 0, status: Status::Active, controls: 0, checkpoints: 0, rests: 0, expect, traj: None }, header) + let dev_full = engine_version(engine).is_some_and(|v| v < DEV_R6_SINCE); + (Live { p, st, clock, last: 0.0, prev, n: 0, status: Status::Active, controls: 0, checkpoints: 0, rests: 0, expect, traj: None, dev_full }, header) } /// One event: stamp its elapsed hours, run the turn, chain the strand line -> (the stance document, the strand line). A bad /// event is an error and changes nothing (no turn, no line); so is every event while the individual is paused or retired. @@ -93,7 +117,7 @@ impl Live { let traj = persona::trajectory(&self.p, &self.st, &inputs); let (mut stance, ns) = persona::turn(&self.p, &self.st, &inputs, false, eng, false)?; let sd = persona::stance_sha(&stance); - if self.expect.is_some() { if let Json::Obj(v) = &mut stance { v.push(("deviation".into(), dev.as_ref().map_or(Json::Null, persona::Deviation::to_json))); } } + if self.expect.is_some() { if let Json::Obj(v) = &mut stance { v.push(("deviation".into(), dev.as_ref().map_or(Json::Null, |d| d.to_json(self.dev_full)))); } } // §2.18: the glyph reads the finished document, `deviation` included (the surprise modifier) persona::set_glyph(&self.p, &mut stance); // RFC 0002 part 2: a rest line due before this event is its own line, chained before the event line (the turn applied it) @@ -282,7 +306,12 @@ impl Replay { if !self.checkpoint_ready { return Err((i, "a checkpoint must immediately follow an event line".into())); } if j.get("checkpoint").and_then(Json::as_f64) != Some(live.n as f64) { return Err((i, format!("the checkpoint is not at event {}", live.n))); } let Some(stance) = self.last.as_ref() else { return Err((i, "a checkpoint follows an event line".into())) }; - if live.checkpoint(stance) != l { return Err((i, "the checkpoint's state or stance differs from the replay".into())); } + let line = live.checkpoint(stance); + if line != l { + // before 0.14.0 the deviation is at full precision: equal at 6 decimals, the chain goes on from the logged bytes + if !(live.dev_full && dev_r6(&line) == dev_r6(l)) { return Err((i, "the checkpoint's state or stance differs from the replay".into())); } + live.prev = persona::digest_of(l); + } self.line = i; self.checkpoint_ready = false; return Ok(None) } Kind::Event => {} } if j.get("n").and_then(Json::as_f64) != Some((live.n + 1) as f64) { return Err((i, format!("n is not {}", live.n + 1))); } @@ -559,7 +588,8 @@ pub fn resume(text: &str, p: &Persona, st: State, clock: Clock) -> Result Date: Mon, 26 Oct 2026 12:00:02 -0700 Subject: [PATCH 03/61] probbit-cli: the parity test compares the 0.13.0 fixture with CRLF normalized (a Windows checkout) --- probbit-cli/src/live.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/probbit-cli/src/live.rs b/probbit-cli/src/live.rs index 524b86c..0f158fe 100644 --- a/probbit-cli/src/live.rs +++ b/probbit-cli/src/live.rs @@ -807,7 +807,7 @@ mod tests { let life = |engine: &str| { let st = State::read(&p, h.get("state").unwrap()).unwrap(); let (mut lv, head) = Live::start(p.clone(), &doc, st, Clock::Fixed, engine); let mut text = format!("{head}\n"); for e in &evs { let (s, l) = lv.event(e, &run).unwrap(); text += &l; text.push('\n'); if lv.n % 4 == 0 { text += &lv.checkpoint(&s); text.push('\n'); } } text }; let (old, new) = (life("probbit 0.13.0"), life("probbit 0.14.0")); - assert_eq!(old, F, "0.13.0's bytes, as recorded"); + assert_eq!(old, F.replace("\r\n", "\n"), "0.13.0's bytes, as recorded (a Windows checkout has CRLF)"); let nums = |t: &str| t.lines().filter_map(|l| json::parse(l).ok()?.get("stance")?.get("deviation").cloned()).flat_map(|d| ["self", "pred", "input", "z"].map(|k| d.get(k).and_then(Json::as_f64))).flatten().collect::>(); let n = nums(&new); assert_eq!(n.len(), 16); assert!(n.iter().all(|x| persona::r6(*x) == *x), "{n:?}"); assert!(nums(&old).iter().any(|x| persona::r6(*x) != *x)); assert_eq!(verify(&new, &run).map(|d| d.get("checkpoints").cloned()), Ok(Some(Json::Num(4.0)))); From 064d37c8e20f2d0d0d3325a74d90bf446f3711cc Mon Sep 17 00:00:00 2001 From: BitmapAsset Date: Mon, 26 Oct 2026 20:49:48 -0700 Subject: [PATCH 04/61] Monitor: replay from the header when the persona has an expectation block and the strand has checkpoints The expectation block (RFC 0009) folds every event line into its memory, and no checkpoint line carries that memory, so `Replay::open_at` refuses to start there. `monitor --once` started at the last checkpoint and exited 1 with that refusal on a valid strand; `--follow`, `--serve` and `--dir` fell back to the header only because the error was dropped. `Watch::start` now decides where a replay starts: from the header when the persona has an expectation block, else from the last checkpoint, as before. Both start paths use it; appended lines still replay one by one from the state in memory. `live verify` and `verify --from-checkpoint` are unchanged. A fixture (the tutor with an expectation block, 22 events, 4 checkpoints, recorded on the fixed clock) opens in `--once` (event 22), streams in `--serve` and lists with its n in `--dir`; the same events through the tutor without the block still start at the last checkpoint. A 5,000-event life with the block opens in about 7.5 s (about 650 events a second on a 16-vCPU Linux VM). --- CHANGELOG.md | 9 + docs/persona.md | 6 +- probbit-cli/src/monitor.rs | 49 ++++- .../monitor/tutor-expect.events.jsonl | 22 +++ .../fixtures/monitor/tutor-expect.strand | 27 +++ .../tests/fixtures/monitor/tutor-expect.yaml | 180 ++++++++++++++++++ probbit-cli/tests/monitor.rs | 36 ++++ 7 files changed, 324 insertions(+), 5 deletions(-) create mode 100644 probbit-cli/tests/fixtures/monitor/tutor-expect.events.jsonl create mode 100644 probbit-cli/tests/fixtures/monitor/tutor-expect.strand create mode 100644 probbit-cli/tests/fixtures/monitor/tutor-expect.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ec4aad..f19aed5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project are documented here. The format follows ## Unreleased +### Fixed + +- `probbit monitor` opens a strand with checkpoint lines when its persona has an `expectation` block: it replays such a + strand from the header (`--once`, `--follow`, `--serve`, and each strand of `--dir` by its own persona) instead of refusing + it at the last checkpoint, which does not carry the expectation's memory. Other personas still start at the last + checkpoint; `live verify` is unchanged. + +### Documentation + - Documentation, from a dress rehearsal of the published v0.13.0 artifacts (the release page's Mac app zip opened as a download, the README and REPRODUCE.md commands with the released binary on Linux and macOS, `probbit-stance` 0.2.0 from npm): `docs/monitor-app.md` says to remove the quarantine mark before the first open and how to recover after a refused diff --git a/docs/persona.md b/docs/persona.md index 2d8e2f6..a49bfd4 100644 --- a/docs/persona.md +++ b/docs/persona.md @@ -1573,7 +1573,11 @@ check that inference. Hashes alone do not authenticate a writer or detect a whol anchor. Historical measurement before this hardening, on an Apple M4: a 10,000-event strand of a drives persona (4 goals, a floor, learning; 3,126,444 bytes, 10 checkpoint lines) opens in `monitor --once` from its last checkpoint in 0.005 s (load 3.3), where a full `verify` takes 192 s. A replay from a checkpoint costs what the events after it cost: 500 events past the last one took 26 s to draw -(load 6-13, about 50 ms an event for this persona), so K bounds the wait; a smaller K costs a few KB per checkpoint line. A strand without checkpoint or control lines (shorter than K events, or `--checkpoint-every 0`) is the +(load 6-13, about 50 ms an event for this persona), so K bounds the wait; a smaller K costs a few KB per checkpoint line. A persona with an `expectation` block (RFC 0009) +is the exception: its expectation memory is a fold of every event line, and no checkpoint line carries it, so `monitor` +(`--once`, `--follow`, `--serve`, each strand of `--dir` by its own persona) replays such a strand from its header, every +line, and `verify --from-checkpoint` still refuses it. The cost is a full replay when the strand is opened: on a 16-vCPU Linux VM, a 5,000-event life of the tutor with the block (10 checkpoint lines) opens in `monitor --once` in 7.4 to 7.8 s, about 650 events a second, where the same life without the block opens from its last checkpoint in under 0.05 s; after +that, appended lines replay one by one from the state in memory, as for any strand. A strand without checkpoint or control lines (shorter than K events, or `--checkpoint-every 0`) is the same format as in 0.8.0. New strand headers record the current engine version, so their chain hashes differ; existing 0.8.0 strands remain replayable. Binaries before 0.8.0 do not read checkpoint or control lines. diff --git a/probbit-cli/src/monitor.rs b/probbit-cli/src/monitor.rs index b0ac76d..614dfe2 100644 --- a/probbit-cli/src/monitor.rs +++ b/probbit-cli/src/monitor.rs @@ -127,6 +127,13 @@ impl Watch { let rep = Replay::open(head)?; let meta = Meta::of(&rep); let n = meta.moods.len(); Ok(Watch { rep, meta, last: None, spark: vec![VecDeque::new(); n], bad: None }) } + /// Where the replay of `lines` (the header first) starts: the last checkpoint line (index), or None for the header. RFC 0009: + /// a persona with an expectation block folds every event line into its memory, which no checkpoint carries, so its strand + /// replays from the header (every line, slower; `open_at` refuses that shortcut); any other persona starts at the last one + fn start(lines: &[&str]) -> Option { + let k = crate::live::last_checkpoint(lines, 0).filter(|k| *k >= 2)?; + (!Replay::open(lines[0]).is_ok_and(|r| r.live.p.expect.is_some())).then_some(k) + } /// The replay from a checkpoint line (`live::Replay::open_at`; `before` = the event line before it): the lines before it are /// not replayed, and the board starts at the checkpoint's event (its stance, the inputs `before` logs, its learned deltas) fn open_at(head: &str, before: &str, cp: &str, at: usize) -> Result { @@ -644,10 +651,11 @@ impl Followed { fn poll(&mut self, eng: Engine, each: &mut dyn FnMut(&Watch)) -> Option { match self.tail.poll() { Got::Lines(ls) => { - // the first read of a strand with checkpoint lines starts at the last one: its event is drawn at once + // the first read of a strand with checkpoint lines starts at the last one (`Watch::start`): its event is drawn at + // once; after that, appended lines replay from the state in memory let mut skip = 0; if self.w.is_none() && self.bad_head.is_none() { let refs: Vec<&str> = ls.iter().map(String::as_str).collect(); - if let Some(k) = crate::live::last_checkpoint(&refs, 0).filter(|k| *k >= 2) { + if let Some(k) = Watch::start(&refs) { if let Ok(x) = Watch::open_at(refs[0], refs[k - 1], refs[k], k + 1) { each(&x); self.w = Some(x); skip = k + 1; } } } for l in ls.into_iter().skip(skip) { if let Some(x) = &mut self.w { let n = x.rep.live.n; x.feed(&l, eng); if x.rep.live.n != n && x.bad.is_none() { each(x); } continue; } if self.bad_head.is_none() { match Watch::open(&l) { Ok(x) => self.w = Some(x), Err(e) => self.bad_head = Some(e) } } } @@ -746,8 +754,9 @@ pub fn cmd(args: &[String]) { if head.is_empty() { crate::fail(&format!("monitor: {path} is empty: not a strand")) } if !strand(head) { crate::fail(&format!("monitor: {path} is not a probbit strand (format 1)")) } let note = (!rest.is_empty() && !lines.is_empty()).then(|| format!("line {} is incomplete (no newline at its end): not replayed", lines.len() + 1)); - // a strand with checkpoint lines replays from the last one (the board starts at its event and draws the latest one) - let start = crate::live::last_checkpoint(&lines, 0); + // a strand with checkpoint lines replays from the last one (the board starts at its event and draws the latest one), unless + // its persona has an expectation block: then from the header (`Watch::start`) + let start = Watch::start(&lines); let opened = match start { Some(k) => Watch::open_at(head, lines[k - 1], lines[k], k + 1), None => Watch::open(head) }; let (w, bad_head) = match opened { Ok(mut w) => { for l in lines.iter().skip(start.map_or(1, |k| k + 1)) { w.feed(l, eng); } (Some(w), None) } Err(e) => (None, Some(e)) }; let fr = frame(w.as_ref(), bad_head.as_ref(), &path, note.as_deref(), &s); @@ -1158,6 +1167,38 @@ mod tests { let _ = std::fs::remove_file(&f); } + /// RFC 0009: a persona with an expectation block replays a strand with checkpoint lines from its header (the checkpoint does + /// not carry the expectation's memory): `--once` and the first poll of `--follow` / `--serve` draw the latest event with no + /// checkpoint start, and lines appended after it replay from the state in memory, one by one + #[test] + fn an_expectation_persona_replays_from_the_header() { + let (p, doc) = persona::load(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/monitor/tutor-expect.yaml")).unwrap(); + assert!(p.expect.is_some(), "the fixture has an expectation block"); + let (mut lv, header) = Live::start(p.clone(), &doc, persona::init(&p, Some(2), true, &run), Clock::Fixed, "probbit test"); + let mut text = format!("{header}\n"); + let add = |lv: &mut Live, text: &mut String, t: u32| { let ev = json::parse(&format!(r#"{{"praise":{},"elapsed_hours":1}}"#, t % 3 == 0)).unwrap(); + let (s, line) = lv.event(&ev, &run).unwrap(); *text += &line; text.push('\n'); if lv.n % 5 == 0 { *text += &lv.checkpoint(&s); text.push('\n'); } }; + for t in 0..17u32 { add(&mut lv, &mut text, t); } + let lines = live::lines(&text); let k = live::last_checkpoint(&lines, 0).unwrap(); + assert_eq!((lines.iter().filter(|l| l.starts_with(r#"{"checkpoint":"#)).count(), Watch::start(&lines)), (3, None), "3 checkpoints, no checkpoint start"); + assert!(Replay::open_at(lines[0], lines[k - 1], lines[k], k + 1).is_err_and(|e| e.1.contains("expectation block")), "the shortcut itself still refuses it"); + let f = std::env::temp_dir().join(format!("probbit-monitor-expect-{}.strand", std::process::id())); std::fs::write(&f, &text).unwrap(); + let mut fl = Followed::new(f.to_str().unwrap()); let mut seen = vec![]; + assert_eq!(fl.poll(&run, &mut |w: &Watch| seen.push(w.rep.live.n)), Some(false)); + let w = fl.w.as_ref().unwrap(); assert!(w.bad.is_none(), "{:?}", w.bad); + assert_eq!((w.rep.from, w.rep.live.n, seen.first().copied(), seen.len()), (None, 17, Some(1), 17), "every event replayed from the header"); + let mut full = Watch::open(lines[0]).unwrap(); for l in &lines[1..] { full.feed(l, &run); } + assert_eq!(persona::canon(&w.last.as_ref().unwrap().doc), persona::canon(&full.last.as_ref().unwrap().doc)); + assert_eq!(frame_json(fl.w.as_ref(), None, None).get("from_checkpoint"), Some(&Json::Null)); + // appended: only the new lines replay, on the state in memory + for t in 17..21u32 { add(&mut lv, &mut text, t); } std::fs::write(&f, &text).unwrap(); + seen.clear(); assert_eq!(fl.poll(&run, &mut |w: &Watch| seen.push(w.rep.live.n)), Some(false)); + assert_eq!(seen, vec![18, 19, 20, 21]); assert!(fl.w.as_ref().unwrap().bad.is_none()); + assert_eq!(fl.w.as_ref().unwrap().rep.summary(), live::verify(&text, &run).unwrap(), "ends where verify does"); + let _ = std::fs::remove_file(&f); + // the tutor without the block takes the shortcut: `following_starts_at_the_last_checkpoint` + } + /// Watch a strand line by line: every document's sha256 is its line's `stance` digest, and the replay ends where `verify` does fn replays_to_its_digests(text: &str, name: &str) -> Watch { let lines = live::lines(text); diff --git a/probbit-cli/tests/fixtures/monitor/tutor-expect.events.jsonl b/probbit-cli/tests/fixtures/monitor/tutor-expect.events.jsonl new file mode 100644 index 0000000..5d77426 --- /dev/null +++ b/probbit-cli/tests/fixtures/monitor/tutor-expect.events.jsonl @@ -0,0 +1,22 @@ +{"praise":true,"elapsed_hours":0.5} +{"loss":true,"elapsed_hours":1} +{"elapsed_hours":2} +{"error":true,"elapsed_hours":13} +{"praise":true,"elapsed_hours":1} +{"loss":true,"elapsed_hours":0.5} +{"elapsed_hours":1} +{"error":true,"elapsed_hours":2} +{"praise":true,"elapsed_hours":13} +{"loss":true,"elapsed_hours":1} +{"elapsed_hours":0.5} +{"error":true,"elapsed_hours":1} +{"praise":true,"elapsed_hours":2} +{"loss":true,"elapsed_hours":13} +{"elapsed_hours":1} +{"error":true,"elapsed_hours":0.5} +{"praise":true,"elapsed_hours":1} +{"loss":true,"elapsed_hours":2} +{"elapsed_hours":13} +{"error":true,"elapsed_hours":1} +{"praise":true,"elapsed_hours":0.5} +{"loss":true,"elapsed_hours":1} diff --git a/probbit-cli/tests/fixtures/monitor/tutor-expect.strand b/probbit-cli/tests/fixtures/monitor/tutor-expect.strand new file mode 100644 index 0000000..57fd98a --- /dev/null +++ b/probbit-cli/tests/fixtures/monitor/tutor-expect.strand @@ -0,0 +1,27 @@ +{"probbit_strand":1,"engine":"probbit 0.13.0","persona":{"name":"Pip","version":"1.1.0","digest":"sha256:58ed82d46afa6efb027d462f488a6c5ebebefe32ddc678940a64846bfe21575c"},"seed":2,"state":{"digest":"sha256:eef51fdf809d2a63d5f9f2fe90bca59c5f319881cdebe0ce6fb35de60b7aaf92","genes":{"react":{"praise":0.7851},"shift":{"arousal":0.214,"caution":-0.3903,"curiosity":0.3383,"directness":0.0956,"emoji":0.959,"formality":-0.3644,"humour":-2.1939,"initiative":0.1127,"valence":-0.0338,"verbosity":-0.2809,"warmth":0.268}},"history":{"confused":[],"error":[]},"mood":{"arousal":[0,0,0],"valence":[0,0,0]},"persona":{"digest":"sha256:58ed82d46afa6efb027d462f488a6c5ebebefe32ddc678940a64846bfe21575c","name":"Pip","version":"1.1.0"},"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":"steady","caution":"measured","curiosity":"exploring","directness":"balanced","emoji":"rich","formality":"casual","humour":"light","initiative":"suggest","valence":"up","verbosity":"full","warmth":"warm"},"seed":2,"turn":0},"document":{"probbit_persona":1,"identity":{"name":"Pip","version":"1.1.0","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},{"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,"warmth":0.6,"humour":-1,"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,"humour":-2,"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":"no_play_when_upset","when":{"sentiment":"negative"},"then":{"humour":{"at_most":"light"}},"say":"go easy on the jokes"},{"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"]},"expectation":{}}} +{"deviation":{"input":0,"pred":0.20781,"self":0.20781},"inputs":{"elapsed_hours":0.5,"praise":true},"n":1,"prev":"sha256:7e9df458eb9b480cbbe1cc35394eae47df68d2beaf719d00a243db88f6285827","stance":"sha256:016fef69c5e036b83b4b5e8a704a166053bf75333fcfe643b91a01798431c113","state":"sha256:1bcf9a02bf0454969e10442b3d4ebefc18f69df15734c3e5af5e0d0189f0ef60"} +{"deviation":{"input":0.69315,"pred":2.8423,"self":2.2116},"inputs":{"elapsed_hours":1,"loss":true},"n":2,"prev":"sha256:ae314cf23a00bbd7e4a0a78ba77631649bd00dd4b37c1f55225d1e090f4f3010","stance":"sha256:afb1164f2022b3646092919c3627a6652132f9d6b86cbc747f6394eab02de22e","state":"sha256:c79a11b1406d5373bcb2f873fc3d99857b6e78a23a75be3b4de9a43f830a2114"} +{"deviation":{"input":1.0914,"pred":0.091607,"self":0},"inputs":{"elapsed_hours":2},"n":3,"prev":"sha256:74b1f36de9ffd3fb0fe5a8e08eb36941216d97dd08579f18f7af003b0c935ea4","stance":"sha256:541049d8ecde4c92f08cb234ea6cbf5040017d93dc276b16f457120b24c967d3","state":"sha256:207b7f6a8bb56cefac90f79dbb8cea06f5db518abd4229a937ed8a684ccf85f2"} +{"deviation":{"input":0.67706,"pred":2.7765,"self":2.8336},"inputs":{"elapsed_hours":13,"error":true},"n":4,"prev":"sha256:93b2cd22ff409fba27ba33abd44bb3214295e611fe0e8165271b9c3168d8b64e","stance":"sha256:019959c6bf2d5d60eadb2e4c89fefe7151dd67c6491b1f2eb4c161b7448f0a86","state":"sha256:e85069f361ff10bac7fd3f35cfb5e2ecd148f72214c2bba5ff3cfbd4992ea9fa"} +{"deviation":{"input":1.6487,"pred":0.66312,"self":0.21638},"inputs":{"elapsed_hours":1,"praise":true},"n":5,"prev":"sha256:2e12dab1a54c0b75bd06a29ff191cef7ae34b9938dbb20cbf5b3cbfd2e7edcf2","stance":"sha256:8daaddad69d74b08f223aba03aafa4ec0a5f90decb3bf9ec6d3d9486e1b4fb06","state":"sha256:14bc4af3f729caebb13dbafc92885087d8c8ccfb25082dda2db05f058bdc9198"} +{"checkpoint":5,"prev":"sha256:ebb3d4d0ca05407c4ab2a494edc5ab495a0227b24a303fd525e0a5520b281729","stance":{"agenda":["encourage","explain","example","check"],"deviation":{"cold":false,"input":1.6487454934364103,"pred":0.663115886379269,"registers":false,"self":0.2163809366922902,"z":1.354697},"engine":{"program":"sha256:95a9314d809c73dc9d9b7a9496fa816337d890c576fe0ceed2d017ff4550cd44","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; a light joke is welcome; warm and encouraging; suggest what to try next; ask what they think first.","line_tokens":39,"mood":{"arousal":{"level":"steady","odds":{"calm":0.280269,"keyed":0.329436,"steady":0.390295},"p":0.390295,"released":true},"valence":{"level":"up","odds":{"down":0.085635,"even":0.417117,"up":0.497249},"p":0.497249,"released":true}},"persona":{"digest":"sha256:58ed82d46afa6efb027d462f488a6c5ebebefe32ddc678940a64846bfe21575c","name":"Pip","version":"1.1.0"},"probbit_persona_turn":1,"seed":2,"stance":{"caution":{"level":"measured","odds":{"bold":0.350191,"careful":0.128347,"measured":0.521462},"p":0.521462,"released":true},"curiosity":{"level":"exploring","odds":{"exploring":0.580967,"focused":0.084846,"open":0.334187},"p":0.580967,"released":true},"directness":{"level":"balanced","odds":{"balanced":0.464394,"blunt":0.113552,"gentle":0.422054},"p":0.464394,"released":true},"emoji":{"level":"rich","odds":{"none":0.037498,"rich":0.640842,"sparse":0.32166},"p":0.640842,"released":true},"formality":{"level":"casual","odds":{"casual":0.755025,"formal":0.001089,"neutral":0.243887},"p":0.755025,"released":true},"humour":{"level":"light","odds":{"light":0.554496,"none":0.386132,"playful":0.059371},"p":0.554496,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.176135,"lead":0.330998,"suggest":0.492867},"p":0.492867,"released":true},"verbosity":{"level":"full","odds":{"full":0.453824,"short":0.412439,"terse":0.133737},"p":0.453824,"released":true},"warmth":{"level":"warm","odds":{"cool":0.01642,"neutral":0.183144,"warm":0.800436},"p":0.800436,"released":true}},"state_digest":"sha256:14bc4af3f729caebb13dbafc92885087d8c8ccfb25082dda2db05f058bdc9198","status":"ok","turn":4,"unsure":[],"why":"one flourish at a time (habit); praise -> valence up, humour up"},"state":{"digest":"sha256:14bc4af3f729caebb13dbafc92885087d8c8ccfb25082dda2db05f058bdc9198","genes":{"react":{"praise":0.7851},"shift":{"arousal":0.214,"caution":-0.3903,"curiosity":0.3383,"directness":0.0956,"emoji":0.959,"formality":-0.3644,"humour":-2.1939,"initiative":0.1127,"valence":-0.0338,"verbosity":-0.2809,"warmth":0.268}},"history":{"confused":[false,false,false,false],"error":[false,false,false,true,false]},"mood":{"arousal":[0,0,0],"valence":[-0.348016,0,0.348016]},"persona":{"digest":"sha256:58ed82d46afa6efb027d462f488a6c5ebebefe32ddc678940a64846bfe21575c","name":"Pip","version":"1.1.0"},"prev":{"agenda":["encourage","explain","example","check"],"arousal":"steady","caution":"measured","curiosity":"exploring","directness":"balanced","emoji":"rich","formality":"casual","humour":"light","initiative":"suggest","valence":"up","verbosity":"full","warmth":"warm"},"probbit_persona_state":1,"rest":{"arousal":"steady","caution":"measured","curiosity":"exploring","directness":"balanced","emoji":"rich","formality":"casual","humour":"light","initiative":"suggest","valence":"up","verbosity":"full","warmth":"warm"},"seed":2,"turn":5}} +{"deviation":{"input":1.8212,"pred":1.8311,"self":2.1799},"inputs":{"elapsed_hours":0.5,"loss":true},"n":6,"prev":"sha256:691b6399093b47507c5ff3a2159d7ee44a75eabf9961d436161cdc7e2ad54f19","stance":"sha256:96bc79dd0fd3ced3867e8b3a37e83662fd983631938a22e727b6be78c5c0db94","state":"sha256:9cd5ea7e03aa5a16de710ede3ea7c3bf7fbb0dcaeb9d5b02c35fe143fc2a8e46"} +{"deviation":{"input":1.2391,"pred":0.1417,"self":0},"inputs":{"elapsed_hours":1},"n":7,"prev":"sha256:33b8db219356dab0921c72ad7ec386640708520a6131b814b122c99768ccba42","stance":"sha256:6c39be8da20202ed2744771641966f0e5ec87ea0cb708b7a85c64b4f4086b268","state":"sha256:0a32589256e6e254996652de8f423d03d5bda926b2b81eb0b77896e66f4913c8"} +{"deviation":{"input":2.0886,"pred":2.0414,"self":2.8317},"inputs":{"elapsed_hours":2,"error":true},"n":8,"prev":"sha256:efa74ba1c5e4ba18c5d1163866a6349ded96d042b81ac8ad19966da22bc75a40","stance":"sha256:7a662e50d3bad0352781a7d641e654b89b6f77d0489269c5838d4410021725d3","state":"sha256:2510474026de865306b0aed7b69f11e857eeff840006a4ff5f4c881cbcb4a6c7"} +{"deviation":{"input":1.5454,"pred":0.73484,"self":0.21337},"inputs":{"elapsed_hours":13,"praise":true},"n":9,"prev":"sha256:867188510f981fa92463e085740d9a0cf26f53f2344ca75a76baf26252cd691d","stance":"sha256:30f92c2616e3b38901fc660f61b4556fe18f8fe7cd60601fe729ef82b6294efb","state":"sha256:fbe42a6a03239aef066ddcd908f8b65dea2cf4d2349d6ba62f9ad192630acdb9"} +{"deviation":{"input":1.6406,"pred":1.7092,"self":2.1896},"inputs":{"elapsed_hours":1,"loss":true},"n":10,"prev":"sha256:8e0b586b21d0ffeecd6ed1a91aed3b53def6f5d87b8d7e4bd0d8c51bb5f23308","stance":"sha256:f764a6f800ac7364e6c76c672ce7b8dc001fb7d8f63f8cd33107a6d83e334dc5","state":"sha256:225e7dfd9eb78d673993cae884d4be5335fcd21b779d7998ac6ce3d4b2672fcd"} +{"checkpoint":10,"prev":"sha256:cc852f6ea39d2367b6123a6066a8ada50914135bab5008ee319e270a158b0a61","stance":{"agenda":["encourage","explain","example","check"],"deviation":{"cold":false,"input":1.6405653534874491,"pred":1.709166276250666,"registers":true,"self":2.1895837322588894,"z":2.288624},"engine":{"program":"sha256:b82832d8dc76cef7147d0b343220cf0b8e413148667b2d51c966c7e45b4c9d0f","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":"neutral","stakes":0,"time_pressure":0},"line":"Stance: no jokes, be kind; one emoji at most; explain step by step; warm and encouraging; suggest what to try next; ask what they think first; casual.","line_tokens":38,"mood":{"arousal":{"level":"steady","odds":{"calm":0.280269,"keyed":0.329436,"steady":0.390295},"p":0.390295,"released":true},"valence":{"level":"down","odds":{"down":0.541495,"even":0.405777,"up":0.052728},"p":0.541495,"released":true}},"persona":{"digest":"sha256:58ed82d46afa6efb027d462f488a6c5ebebefe32ddc678940a64846bfe21575c","name":"Pip","version":"1.1.0"},"probbit_persona_turn":1,"seed":2,"stance":{"caution":{"level":"measured","odds":{"bold":0.350191,"careful":0.128347,"measured":0.521462},"p":0.521462,"released":true},"curiosity":{"level":"exploring","odds":{"exploring":0.580967,"focused":0.084846,"open":0.334187},"p":0.580967,"released":true},"directness":{"level":"balanced","odds":{"balanced":0.464394,"blunt":0.113552,"gentle":0.422054},"p":0.464394,"released":true},"emoji":{"level":"sparse","odds":{"none":0.416584,"rich":0,"sparse":0.583416},"p":0.583416,"released":true},"formality":{"level":"casual","odds":{"casual":0.690526,"formal":0.013291,"neutral":0.296183},"p":0.690526,"released":true},"humour":{"level":"none","odds":{"light":0,"none":1,"playful":0},"p":1,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.176135,"lead":0.330998,"suggest":0.492867},"p":0.492867,"released":true},"verbosity":{"level":"full","odds":{"full":0.453824,"short":0.412439,"terse":0.133737},"p":0.453824,"released":true},"warmth":{"level":"warm","odds":{"cool":0.002982,"neutral":0.08223,"warm":0.914788},"p":0.914788,"released":true}},"state_digest":"sha256:225e7dfd9eb78d673993cae884d4be5335fcd21b779d7998ac6ce3d4b2672fcd","status":"ok","turn":9,"unsure":[],"why":"learner reports a failure -> humour down, valence down"},"state":{"digest":"sha256:225e7dfd9eb78d673993cae884d4be5335fcd21b779d7998ac6ce3d4b2672fcd","genes":{"react":{"praise":0.7851},"shift":{"arousal":0.214,"caution":-0.3903,"curiosity":0.3383,"directness":0.0956,"emoji":0.959,"formality":-0.3644,"humour":-2.1939,"initiative":0.1127,"valence":-0.0338,"verbosity":-0.2809,"warmth":0.268}},"history":{"confused":[false,false,false,false],"error":[false,false,true,false,false]},"mood":{"arousal":[0,0,0],"valence":[0.614733,0,-0.614733]},"persona":{"digest":"sha256:58ed82d46afa6efb027d462f488a6c5ebebefe32ddc678940a64846bfe21575c","name":"Pip","version":"1.1.0"},"prev":{"agenda":["encourage","explain","example","check"],"arousal":"steady","caution":"measured","curiosity":"exploring","directness":"balanced","emoji":"sparse","formality":"casual","humour":"none","initiative":"suggest","valence":"down","verbosity":"full","warmth":"warm"},"probbit_persona_state":1,"rest":{"arousal":"steady","caution":"measured","curiosity":"exploring","directness":"balanced","emoji":"rich","formality":"casual","humour":"light","initiative":"suggest","valence":"up","verbosity":"full","warmth":"warm"},"seed":2,"turn":10}} +{"deviation":{"input":1.2831,"pred":0.1713,"self":0},"inputs":{"elapsed_hours":0.5},"n":11,"prev":"sha256:0399dee7985c4b6e32fe79505187d1edde3519dd9c1bb8bd3c432cdb36435951","stance":"sha256:61deb67957b520ae6c81fb86e6baed41a10d13c37ec579f97b5515119e05180a","state":"sha256:9b0fffb260a9d2d317b8c6d2a2704a6c38331923aaad66822ae32f81019495dd"} +{"deviation":{"input":1.8024,"pred":1.8623,"self":2.8315},"inputs":{"elapsed_hours":1,"error":true},"n":12,"prev":"sha256:4b98762c333e63d5d0a81398aeb4236fb0ce6cebeabd737ee9675b1246adc7fa","stance":"sha256:d5276ba7bbdf776c324f04cfb5e00d06f98081ef0a03ef09aad2de2dce78c2d7","state":"sha256:daffac198a0ce7f3a8cdf7270c1807ce41c54f6918e37bcadde7755a4ea1866b"} +{"deviation":{"input":1.5086,"pred":0.76222,"self":0.21827},"inputs":{"elapsed_hours":2,"praise":true},"n":13,"prev":"sha256:390563c827d2aad8bd94bb20f77413258dee0175ddb37c5fd69ac6c86c0d0709","stance":"sha256:13ff4842a26ea33d98dfe343fd835a3f14c93011f43b142a2a86abc83076309e","state":"sha256:f0ccc4aed92c3f03b6dfc356d4231660bc643d164cd1d8e402bcb6444b474eeb"} +{"deviation":{"input":1.5725,"pred":1.628,"self":2.1228},"inputs":{"elapsed_hours":13,"loss":true},"n":14,"prev":"sha256:359f6e4df83d6fba50bb60abc1e97fa79f51134784456d48b0ebbdea622a4f23","stance":"sha256:f586b7c7a2643eb010d82d99ee5314e9abd215716ea9dd36b005bf4d18ae957f","state":"sha256:2c2d7b9e7e66d7dd7050ecf7101666ecbfe9be0ebe0abbf31c0d1be19c64fc32"} +{"deviation":{"input":1.3042,"pred":0.18472,"self":0},"inputs":{"elapsed_hours":1},"n":15,"prev":"sha256:af88a285bfb51bdaa1d5af3c3a0e080e600c3b98ff93d7b79e09085cb99b9316","stance":"sha256:0a186aad66b1f2b853088abda07fbf6395730994c69599082b30a8d57d29f7b6","state":"sha256:9f270112de0ba5b00c5f5b1bc802b751bdb81c8c9d9bda484ab65f1db80b86ba"} +{"checkpoint":15,"prev":"sha256:62d72cf3d2a58dbdf915c6f3a5577a461242f38707c4efdc1d0bd2047c12c320","stance":{"agenda":["encourage","explain","example","check"],"deviation":{"cold":false,"input":1.3042172408804977,"pred":0.18472371562460088,"registers":false,"self":0,"z":-0.044533},"engine":{"program":"sha256:9ad02ae3f33a68b43dcf5443385d4ea81abbcd26ea557efd1f6314a9987434f0","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: no jokes; explain step by step; warm and encouraging; suggest what to try next; ask what they think first; emoji welcome; casual.","line_tokens":35,"mood":{"arousal":{"level":"steady","odds":{"calm":0.280269,"keyed":0.329436,"steady":0.390295},"p":0.390295,"released":true},"valence":{"level":"even","odds":{"down":0.25337,"even":0.4997,"up":0.24693},"p":0.4997,"released":true}},"persona":{"digest":"sha256:58ed82d46afa6efb027d462f488a6c5ebebefe32ddc678940a64846bfe21575c","name":"Pip","version":"1.1.0"},"probbit_persona_turn":1,"seed":2,"stance":{"caution":{"level":"measured","odds":{"bold":0.350191,"careful":0.128347,"measured":0.521462},"p":0.521462,"released":true},"curiosity":{"level":"exploring","odds":{"exploring":0.580967,"focused":0.084846,"open":0.334187},"p":0.580967,"released":true},"directness":{"level":"balanced","odds":{"balanced":0.464394,"blunt":0.113552,"gentle":0.422054},"p":0.464394,"released":true},"emoji":{"level":"rich","odds":{"none":0.101772,"rich":0.480668,"sparse":0.41756},"p":0.480668,"released":true},"formality":{"level":"casual","odds":{"casual":0.72774,"formal":0.003406,"neutral":0.268853},"p":0.72774,"released":true},"humour":{"level":"none","odds":{"light":0.377719,"none":0.590719,"playful":0.031562},"p":0.590719,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.176135,"lead":0.330998,"suggest":0.492867},"p":0.492867,"released":true},"verbosity":{"level":"full","odds":{"full":0.453824,"short":0.412439,"terse":0.133737},"p":0.453824,"released":true},"warmth":{"level":"warm","odds":{"cool":0.01711,"neutral":0.185902,"warm":0.796988},"p":0.796988,"released":true}},"state_digest":"sha256:9f270112de0ba5b00c5f5b1bc802b751bdb81c8c9d9bda484ab65f1db80b86ba","status":"ok","turn":14,"unsure":[],"why":"baseline (no live evidence)"},"state":{"digest":"sha256:9f270112de0ba5b00c5f5b1bc802b751bdb81c8c9d9bda484ab65f1db80b86ba","genes":{"react":{"praise":0.7851},"shift":{"arousal":0.214,"caution":-0.3903,"curiosity":0.3383,"directness":0.0956,"emoji":0.959,"formality":-0.3644,"humour":-2.1939,"initiative":0.1127,"valence":-0.0338,"verbosity":-0.2809,"warmth":0.268}},"history":{"confused":[false,false,false,false],"error":[false,true,false,false,false]},"mood":{"arousal":[0,0,0],"valence":[0.342003,0,-0.342003]},"persona":{"digest":"sha256:58ed82d46afa6efb027d462f488a6c5ebebefe32ddc678940a64846bfe21575c","name":"Pip","version":"1.1.0"},"prev":{"agenda":["encourage","explain","example","check"],"arousal":"steady","caution":"measured","curiosity":"exploring","directness":"balanced","emoji":"rich","formality":"casual","humour":"none","initiative":"suggest","valence":"even","verbosity":"full","warmth":"warm"},"probbit_persona_state":1,"rest":{"arousal":"steady","caution":"measured","curiosity":"exploring","directness":"balanced","emoji":"rich","formality":"casual","humour":"light","initiative":"suggest","valence":"up","verbosity":"full","warmth":"warm"},"seed":2,"turn":15}} +{"deviation":{"input":1.6857,"pred":1.7805,"self":2.8308},"inputs":{"elapsed_hours":0.5,"error":true},"n":16,"prev":"sha256:05d854ca158ab2d6fe5b310d0d4b785703556925e175ac591520710d908ee9b4","stance":"sha256:14490dfe573bbd90bc91901e7372278758d1c8c9bcbbf8b45a4c50e03f04f0a9","state":"sha256:3ce4d0cd4fcc67529de310a5b07ea0f348b24d4834362906eed981f4490bd3b3"} +{"deviation":{"input":1.4897,"pred":0.77684,"self":0.21961},"inputs":{"elapsed_hours":1,"praise":true},"n":17,"prev":"sha256:df2efee5cc1f4a517cc77a8bd56c30d99c46e0e5344d863e64ec3cdb1ab3389f","stance":"sha256:d5a39913357d01edeed04e798e16730dafe97ecb406031501e32ffa33d3cba76","state":"sha256:5a2e0ed7a588a78f8bb697bf11bc9a241e74bfd043976ee1a77e2a0de965edb3"} +{"deviation":{"input":1.5368,"pred":1.6188,"self":2.158},"inputs":{"elapsed_hours":2,"loss":true},"n":18,"prev":"sha256:9c9e0bd9e30eb4cf3a60f011d3a76ef9c788e2ed726c8afe5344b9158c5edd2b","stance":"sha256:f759e0c8adc25d43e6dc138c3cfed372c5a8016615ea1c23a6b65827c53939dc","state":"sha256:11816113854730ab1bd7091c290bc0f20ca9a870513e4437511bb9a17bcf179b"} +{"deviation":{"input":1.3166,"pred":0.20393,"self":0},"inputs":{"elapsed_hours":13},"n":19,"prev":"sha256:171d3e3fe1407a4df0b1adb83b70673976aca386ccc033de8f0cd375f04a6217","stance":"sha256:7e720363c8afa260bb42a0a8c105ab20cae1fa30761f8f38aaa9a37c9525bebd","state":"sha256:fc62ce1c64e52ce54c11951280a068f335f5f7a746285fae51cd26e81ad9ddf5"} +{"deviation":{"input":1.6221,"pred":1.7338,"self":2.8333},"inputs":{"elapsed_hours":1,"error":true},"n":20,"prev":"sha256:654d45575a01362b56990acc0f64f2e9d9f236d30d226ade9b3651da34582a2d","stance":"sha256:131e8d2319a49d677e53a0d596829aa67fabee60e4fc50b8063d18210f4c6364","state":"sha256:2ea90ddecba0a2b1dc2481f28df71bb284a8d3f7df8d3d4f9b8446f245ac260e"} +{"checkpoint":20,"prev":"sha256:fa0af32af76d00d7877ea4dd78c798b4f896c19eae6f790596ec6f932395f0f1","stance":{"agenda":["encourage","explain","example","check"],"deviation":{"cold":false,"input":1.622062690482329,"pred":1.7338026105253461,"registers":true,"self":2.8332527443993802,"z":1.791988},"engine":{"program":"sha256:a001a7625ac3d6497dc19537b9303245237d4ac0312e696da127822fb30ff2f0","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},"line":"Stance: shorter, double-check; a short explanation; no jokes; double-check every step; warm and encouraging; suggest what to try next.","line_tokens":34,"mood":{"arousal":{"level":"steady","odds":{"calm":0.210437,"keyed":0.406074,"steady":0.383489},"p":0.383489,"released":true},"valence":{"level":"even","odds":{"down":0.280285,"even":0.507669,"up":0.212046},"p":0.507669,"released":true}},"persona":{"digest":"sha256:58ed82d46afa6efb027d462f488a6c5ebebefe32ddc678940a64846bfe21575c","name":"Pip","version":"1.1.0"},"probbit_persona_turn":1,"seed":2,"stance":{"caution":{"level":"careful","odds":{"bold":0,"careful":1,"measured":0},"p":1,"released":true},"curiosity":{"level":"exploring","odds":{"exploring":0.526402,"focused":0.108794,"open":0.364804},"p":0.526402,"released":true},"directness":{"level":"balanced","odds":{"balanced":0.464394,"blunt":0.113552,"gentle":0.422054},"p":0.464394,"released":true},"emoji":{"level":"rich","odds":{"none":0.113298,"rich":0.466202,"sparse":0.4205},"p":0.466202,"released":true},"formality":{"level":"casual","odds":{"casual":0.705532,"formal":0.004117,"neutral":0.29035},"p":0.705532,"released":true},"humour":{"level":"none","odds":{"light":0.22708,"none":0.764258,"playful":0.008662},"p":0.764258,"released":true},"initiative":{"level":"suggest","odds":{"follow":0.176135,"lead":0.330998,"suggest":0.492867},"p":0.492867,"released":true},"verbosity":{"level":"short","odds":{"full":0,"short":0.695554,"terse":0.304446},"p":0.695554,"released":true},"warmth":{"level":"warm","odds":{"cool":0.017612,"neutral":0.188021,"warm":0.794367},"p":0.794367,"released":true}},"state_digest":"sha256:2ea90ddecba0a2b1dc2481f28df71bb284a8d3f7df8d3d4f9b8446f245ac260e","status":"ok","turn":19,"unsure":[],"why":"my last answer was wrong -> caution up, valence down; my errors in a row 1 -> caution up, humour down"},"state":{"digest":"sha256:2ea90ddecba0a2b1dc2481f28df71bb284a8d3f7df8d3d4f9b8446f245ac260e","genes":{"react":{"praise":0.7851},"shift":{"arousal":0.214,"caution":-0.3903,"curiosity":0.3383,"directness":0.0956,"emoji":0.959,"formality":-0.3644,"humour":-2.1939,"initiative":0.1127,"valence":-0.0338,"verbosity":-0.2809,"warmth":0.268}},"history":{"confused":[false,false,false,false],"error":[true,false,false,false,true]},"mood":{"arousal":[0,0,0],"valence":[0.274294,0,-0.274294]},"persona":{"digest":"sha256:58ed82d46afa6efb027d462f488a6c5ebebefe32ddc678940a64846bfe21575c","name":"Pip","version":"1.1.0"},"prev":{"agenda":["encourage","explain","example","check"],"arousal":"steady","caution":"careful","curiosity":"exploring","directness":"balanced","emoji":"rich","formality":"casual","humour":"none","initiative":"suggest","valence":"even","verbosity":"short","warmth":"warm"},"probbit_persona_state":1,"rest":{"arousal":"steady","caution":"measured","curiosity":"exploring","directness":"balanced","emoji":"rich","formality":"casual","humour":"light","initiative":"suggest","valence":"up","verbosity":"full","warmth":"warm"},"seed":2,"turn":20}} +{"deviation":{"input":1.4782,"pred":0.78713,"self":0.21706},"inputs":{"elapsed_hours":0.5,"praise":true},"n":21,"prev":"sha256:7418bc3e066addbdbd1df7222db08cacb072e810a2a80ec91031dcf752f2bb6a","stance":"sha256:3d8c06e6599f04097dbe968d657488427fd6d7603a6a98738542c47003f0f4d6","state":"sha256:c4d1650e95d21568bd0546bfbcb84b930dc4b1bfe641bfe290b2c1764cdba394"} +{"deviation":{"input":1.5148,"pred":1.61,"self":2.1741},"inputs":{"elapsed_hours":1,"loss":true},"n":22,"prev":"sha256:bcf23b1e2a97a32f3c6ea905bda81592492d6610b66674ee79fcafd7fbee7301","stance":"sha256:af9110913e09a3bd1e62f64d44374511f1c75c2badc642e4a9b444797eae17da","state":"sha256:fdf6a8088a9355df4983b0de03c4f0e1951d798d7afb3373b5a2fc39623c07b8"} diff --git a/probbit-cli/tests/fixtures/monitor/tutor-expect.yaml b/probbit-cli/tests/fixtures/monitor/tutor-expect.yaml new file mode 100644 index 0000000..4a5f537 --- /dev/null +++ b/probbit-cli/tests/fixtures/monitor/tutor-expect.yaml @@ -0,0 +1,180 @@ +# The tutor (examples/persona/tutor.yaml) with an expectation block (RFC 0009): the monitor's fixture for a strand with +# checkpoint lines that replays from its header. The block is outside the persona digest and moves no stance. +# A fictional persona: a playful tutor. probbit persona format 1 (docs/persona.md). +probbit_persona: 1 +identity: + name: Pip + version: 1.1.0 + 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: no_play_when_upset + when: {sentiment: negative} + then: {humour: {at_most: light}} + say: go easy on the jokes # added in 1.1.0: one upset message could make some individuals playful (found by `fuzz`) + - 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] + +expectation: {} diff --git a/probbit-cli/tests/monitor.rs b/probbit-cli/tests/monitor.rs index 2ae9a3e..57a55c4 100644 --- a/probbit-cli/tests/monitor.rs +++ b/probbit-cli/tests/monitor.rs @@ -463,3 +463,39 @@ fn one_monitor_serves_many_strands() { assert_eq!(get(port, "/s/zeta/doc/1", &host).2, format!("{}\n", db[0])); drop(child); let _ = std::fs::remove_dir_all(&dir); } + +/// RFC 0009: a strand with checkpoint lines whose persona has an expectation block replays from its header (no checkpoint +/// carries the expectation's memory). The fixture is the life of `tutor-expect.yaml` (the tutor with an expectation block) over +/// `tutor-expect.events.jsonl` (22 events, seed 2, the fixed clock, a checkpoint after every 5th: 4 of them), as `probbit live +/// tests/fixtures/monitor/tutor-expect.yaml --seed 2 --clock fixed --events ... --checkpoint-every 5 --strand` writes it. `--once` draws event 22 and exits 0, `--serve` streams it, `--dir` +/// lists it with its n; the same events through the tutor (no expectation block) still start at the last checkpoint +#[test] +fn checkpointed_strands_of_an_expectation_persona_open() { + const F: &str = "tests/fixtures/monitor/tutor-expect.strand"; + const EV: &str = "tests/fixtures/monitor/tutor-expect.events.jsonl"; + assert_eq!(std::fs::read_to_string(F).unwrap().lines().filter(|l| l.starts_with(r#"{"checkpoint":"#)).count(), 4); + let (c, v, err) = probbit(&["live", "verify", F]); assert_eq!(c, 0, "{v}{err}"); assert!(v.contains(r#""checkpoints":4"#) && v.contains(r#""events":22"#), "{v}"); + let (c, out, err) = probbit(&["monitor", F, "--once", "--plain"]); + assert_eq!(c, 0, "{out}{err}"); let top = out.lines().next().unwrap_or(""); + assert!(top.contains("| event 22 |") && top.ends_with("replay verified [ok]"), "from the header: {top}"); + // the page: the latest frame on /events is event 22, replayed from the header + let (child, port) = served(&[F]); let host = format!("127.0.0.1:{port}"); + let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap(); s.set_read_timeout(Some(Duration::from_secs(20))).unwrap(); + write!(s, "GET /events HTTP/1.1\r\nHost: {host}\r\n\r\n").unwrap(); + let mut buf = String::new(); let evs = stream(&mut s, &mut buf, 2); + assert_eq!(evs[1].0, "frame"); assert!(evs[1].1.contains(r#""from_checkpoint":null"#) && evs[1].1.contains(r#""n":22,"#) && evs[1].1.contains(r#""diverges":null"#), "{}", evs[1].1); + drop(s); drop(child); + // --dir: listed with its n + let dir = std::env::temp_dir().join(format!("probbit-monitor-expect-dir-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); + std::fs::copy(F, dir.join("tutor-expect.strand")).unwrap(); + let (child, port) = served(&["--dir", &dir.to_string_lossy()]); let host = format!("127.0.0.1:{port}"); + let t = Instant::now(); let mut list = String::new(); + while !list.contains(r#""n":22,"#) { assert!(t.elapsed() < Duration::from_secs(10), "listed with n 22: {list}"); std::thread::sleep(Duration::from_millis(100)); list = get(port, "/strands", &host).2; } + assert!(list.contains(r#""id":"tutor-expect""#) && list.contains(r#""persona":"Pip""#), "{list}"); + drop(child); let _ = std::fs::remove_dir_all(&dir); + // a persona without an expectation block: the shortcut (the badge names the checkpoint the replay started from) + let tutor = tmp("expect-tutor"); lived(&tutor, "2", &["--checkpoint-every", "5"], &std::fs::read_to_string(EV).unwrap()); + let (c, out, err) = probbit(&["monitor", &tutor, "--once", "--plain"]); let top = out.lines().next().unwrap_or(""); + assert_eq!(c, 0, "{out}{err}"); assert!(top.contains("| event 22 |") && top.ends_with("replay verified from checkpoint 20 [ok]"), "{top}"); + let _ = std::fs::remove_file(&tutor); +} From 4daf41c73d648c967f2778f11444d9d50b4f7a0f Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 00:02:40 -0700 Subject: [PATCH 05/61] probbit-persona: a heap-returning setup path for small stacks (build_boxed, init_boxed, Live::start_boxed / start_to, resume_boxed), the header streamed in pieces with its sha256, YAML nesting bounded The persona reader recursed through itself once per document layer with every reader inlined into that frame and a Persona held by value at each level; it is now a small dispatcher over out-of-line readers that pass the persona on boxed. Live::start and resume become wrappers over the boxed calls. strand::write_header writes the header line in fixed-size pieces and returns its sha256 (persona::Sha256, fed in pieces; sha256 no longer copies its input), byte for byte the header Live::start returns. The YAML reader refuses block or flow collections nested deeper than 256 (a limit error, as the JSON reader). --- probbit-persona/src/persona.rs | 133 +++++++++++++++++++++++++-------- probbit-persona/src/strand.rs | 79 ++++++++++++++++---- probbit-persona/src/yaml.rs | 46 +++++++++--- 3 files changed, 204 insertions(+), 54 deletions(-) diff --git a/probbit-persona/src/persona.rs b/probbit-persona/src/persona.rs index 81d24fb..fd765b0 100644 --- a/probbit-persona/src/persona.rs +++ b/probbit-persona/src/persona.rs @@ -49,48 +49,65 @@ pub fn py_repr(x: f64) -> String { } /// 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) { +/// Where the canonical writer puts its text: a `String`, or a header streamed in pieces (`strand::write_header`) +pub trait Sink { fn push(&mut self, c: char); fn push_str(&mut self, t: &str); } +impl Sink for String { fn push(&mut self, c: char) { String::push(self, c) } fn push_str(&mut self, t: &str) { String::push_str(self, t) } } +fn canon_num(x: f64, s: &mut impl Sink) { 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) { +fn esc(t: &str, s: &mut impl Sink) { 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) { +/// Compact JSON of `j` into `s` (`sort`: keys sorted at every level, as `canon`; else insertion order, as `text`) +pub fn write_to(j: &Json, sort: bool, s: &mut impl Sink) { 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::Arr(v) => { s.push('['); for (k, x) in v.iter().enumerate() { if k > 0 { s.push(','); } write_to(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('}'); } + s.push('{'); for (k, (key, x)) in o.into_iter().enumerate() { if k > 0 { s.push(','); } esc(key, s); s.push(':'); write_to(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 } +pub fn canon(j: &Json) -> String { let mut s = String::new(); write_to(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) { +pub fn text(j: &Json) -> String { let mut s = String::new(); write_to(j, false, &mut s); s } +/// sha256 of `data` (no copy of it: the streamed `Sha256`) +pub fn sha256(data: &[u8]) -> [u8; 32] { let mut h = Sha256::new(); h.update(data); h.finish() } +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]; +/// sha256 fed in pieces: `update` any number of times, then `finish` (= `sha256` of the pieces concatenated) +pub struct Sha256 { h: [u32; 8], buf: [u8; 64], fill: usize, len: u64 } +impl Default for Sha256 { fn default() -> Self { Self::new() } } +impl Sha256 { + pub fn new() -> Sha256 { Sha256 { h: [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19], buf: [0; 64], fill: 0, len: 0 } } + pub fn update(&mut self, mut d: &[u8]) { + self.len = self.len.wrapping_add(d.len() as u64); + while !d.is_empty() { let k = (64 - self.fill).min(d.len()); self.buf[self.fill..self.fill + k].copy_from_slice(&d[..k]); self.fill += k; d = &d[k..]; + if self.fill == 64 { let b = self.buf; self.block(&b); self.fill = 0; } } + } + pub fn finish(mut self) -> [u8; 32] { + let bits = self.len.wrapping_mul(8); let mut pad = [0u8; 72]; pad[0] = 0x80; let z = (119 - self.fill) % 64; // 0x80, zeros, then 8 length bytes: to a 64-byte boundary + pad[1 + z..9 + z].copy_from_slice(&bits.to_be_bytes()); let len = self.len; self.update(&pad[..9 + z]); self.len = len; + let mut out = [0u8; 32]; for (i, x) in self.h.iter().enumerate() { out[4 * i..4 * i + 4].copy_from_slice(&x.to_be_bytes()); } out + } + fn block(&mut self, c: &[u8; 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; + let mut v = self.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); } + for (x, y) in self.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 @@ -313,15 +330,20 @@ pub fn parse_doc(t: &str, is_json: bool) -> Result { } /// The strict validator: document -> persona (every field type-checked, unknown fields refused at their path). -pub fn build(doc: &Json) -> R { +pub fn build(doc: &Json) -> R { build_boxed(doc).map(|p| *p) } +/// `build` on the heap: the persona is returned boxed and every layer below (`display`, `bound`, `expectation`, the lowered +/// blocks) passes it on boxed, so no frame on the way holds a `Persona` by value; the heavy readers are out of line, so the +/// recursion through the layers holds only this dispatcher's frame (docs/embedding.md "Stack budget"). +#[inline(never)] +pub fn build_boxed(doc: &Json) -> R> { // §2.18: `display` moves nothing (effects {}): it is read against the persona built without it and stays out of the digest, // so adding it to a persona with a recorded life leaves every state and stance digest as it was if let Some(dj) = doc.get("display") { let mut d = doc.clone(); if let Json::Obj(v) = &mut d { v.retain(|(k, _)| k != "display"); } - let mut p = build(&d)?; p.glyphs = display_spec(dj, &p)?; return Ok(p); } + let mut p = build_boxed(&d)?; p.glyphs = display_spec(dj, &p)?; return Ok(p); } // RFC 0004: a `bound` block is read against the persona built without it, and it stays out of the digest, so adding it to a // persona with a recorded life leaves every state, strand and turn byte for byte the same (amendment A1) if let Some(b) = doc.get("bound") { let mut d = doc.clone(); if let Json::Obj(v) = &mut d { v.retain(|(k, _)| k != "bound"); } - let p = build(&d)?; bound::spec(b, &p)?; return Ok(p); } + let p = build_boxed(&d)?; bound::spec(b, &p)?; return Ok(p); } // RFC 0009: `expectation` and `engine.trajectory` move nothing (effects {}): they are read against the persona built without // them and stay out of the digest, so adding either to a persona with a recorded life leaves every state and stance digest as // it was (an `engine` mapping left empty is dropped with them) @@ -332,10 +354,15 @@ pub fn build(doc: &Json) -> R { if let Some(i) = v.iter().position(|(k, _)| k == "engine") { if let Json::Obj(e) = &mut v[i].1 { if let Some(j) = e.iter().position(|(k, _)| k == "trajectory") { tj = Some(e.remove(j).1); } } if v[i].1.as_obj().is_some_and(|e| e.is_empty()) { v.remove(i); } } } - let mut p = build(&d)?; + let mut p = build_boxed(&d)?; p.expect = match ex { None | Some(Json::Null) => None, Some(x) => Some(expect_spec(&x)?) }; p.traj = match tj { None | Some(Json::Null) => None, Some(x) => Some(traj_spec(&x)?) }; return Ok(p); } + build_base(doc) +} +/// The persona without the layers `build_boxed` peels off: the top-level keys, then the lowered blocks or the base reader +#[inline(never)] +fn build_base(doc: &Json) -> R> { const TOP: [&str; 22] = ["probbit_persona", "identity", "traits", "moods", "couplings", "inputs", "history", "habits", "agenda", "engine", "line", "learning", "drives", "reward_from", "senses", "needs", "surprise", "pace", "dispositions", "rest", "load", "comment"]; let kv = keys(doc, "", &TOP, &["probbit_persona", "identity", "traits"])?; @@ -468,7 +495,7 @@ pub fn build(doc: &Json) -> R { 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 learning = match get(kv, "learning").filter(|l| !l.is_null()) { None => None, Some(l) => Some(learning_spec(l, &vars, &inputs)?) }; let reward = match get(kv, "reward_from").filter(|x| !x.is_null()) { None => None, Some(rf) => Some(reward_spec(rf, &learning.as_ref().map_or(vec![], |l| l.from.clone()), &inputs)?) }; - let p = Persona { name, version, seed, digest, vars, steps, slots, say_order, prefer, couplings, inputs, history, habits, eng, max_tokens, order, prefix, learning, drives: None, reward, senses: None, why_past, surprise: None, pace: None, disp: None, rest_b: None, load: None, block, expect: None, traj: None, glyphs: None }; + let p = Box::new(Persona { name, version, seed, digest, vars, steps, slots, say_order, prefer, couplings, inputs, history, habits, eng, max_tokens, order, prefix, learning, drives: None, reward, senses: None, why_past, surprise: None, pace: None, disp: None, rest_b: None, load: None, block, expect: None, traj: None, glyphs: None }); // 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()); } @@ -486,7 +513,8 @@ const BODY: [&str; 5] = ["surprise", "pace", "dispositions", "rest", "load"]; /// field's path): `pace` lowers to the number inputs `pace_up` (box [0, cap − r0]) and `pace_down` (box [0, r0]) carrying its /// effects, after the declared inputs; `surprise` and `dispositions` lower to nothing (they act in the credit and drive steps). The /// rest of the document is read as it is without them. -fn build_body(doc: &Json) -> R { +#[inline(never)] +fn build_body(doc: &Json) -> R> { let kv = doc.as_obj().unwrap(); let block = |k: &str| get(kv, k).filter(|x| !x.is_null()); let has = |k: &str| block(k).is_some(); // Design B: the surprise gate (V-R1, V-R2); the defaults are the values the stand-in passed with let surprise = match block("surprise") { None => None, Some(sj) => { @@ -598,7 +626,7 @@ fn build_body(doc: &Json) -> R { if !ld.effects.is_empty() { x.push(("effects".into(), Json::Obj(ld.effects.iter().map(|(k, w)| (k.clone(), Json::Num(*w))).collect()))); } ins.push(Json::Obj(x)); match d2.iter_mut().find(|(k, _)| k == "inputs") { Some(x) => x.1 = Json::Arr(ins), None => d2.push(("inputs".into(), Json::Arr(ins))) } } - let mut p = build(&Json::Obj(d2))?; + let mut p = build_boxed(&Json::Obj(d2))?; p.digest = sha(doc); p.surprise = surprise; p.pace = pace; p.disp = disp; p.rest_b = rest_b; p.load = load; Ok(p) } @@ -1072,7 +1100,8 @@ fn needs_spec(nj: &Json, goals: &[Goal], senses: &[Sense], win_max: f64, moods: } /// A persona with a `drives` block: the block read strictly, then lowered (see `Drives`) and read as a 0.7.0 persona; the digest is /// the whole document's, the block included. A goal floor needs `pursue` free of multi-variable rules (the lift's soundness, §2.9). -fn build_drives(doc: &Json, senses: &[Sense]) -> R { +#[inline(never)] +fn build_drives(doc: &Json, senses: &[Sense]) -> R> { let kv = doc.as_obj().unwrap(); let drv = get(kv, "drives").unwrap(); let dkv = keys(drv, "drives", &["goals", "wanting", "afterglow", "expectation", "pursue", "effects", "learn_from_surprise", "valence", "comment"], &[])?; let gl = match get(dkv, "goals") { Some(Json::Arr(a)) if (2..=7).contains(&a.len()) => a, _ => return Err(perr("drives.goals", "2 to 7 goals (pursue is one variable, 2-7 levels)")) }; @@ -1185,7 +1214,7 @@ fn build_drives(doc: &Json, senses: &[Sense]) -> R { let synthetic: Vec = ins[n_decl..].iter().map(|x| x.get("id").and_then(Json::as_str).unwrap_or("").to_string()).collect(); let ii = slot(&mut d2, "inputs"); d2[ii].1 = Json::Arr(ins); // an error in a synthetic input is reported at the drives path it comes from - let mut p = build(&Json::Obj(d2)).map_err(|e| { for (j, o) in origins.iter().enumerate() { let pre = format!("inputs[{}]", n_decl + j); + let mut p = build_boxed(&Json::Obj(d2)).map_err(|e| { for (j, o) in origins.iter().enumerate() { let pre = format!("inputs[{}]", n_decl + j); if let Some(rest) = e.path.strip_prefix(&pre).filter(|r| r.is_empty() || r.starts_with('.') || r.starts_with('[')) { return perr(&format!("{o}{}", rest.strip_prefix(".effects").unwrap_or("")), e.msg); } } e })?; p.digest = sha(doc); if goals.iter().any(|g| g.floor > 0.0) { for h in &p.habits { for (k, lst) in &h.rules { for r in lst { let vs = rule_vars(k, r); @@ -1886,7 +1915,8 @@ fn sense_cond_text(sn: &Senses, c: &SenseCond) -> String { } /// A persona with a `senses` block: the block read strictly, then lowered (see `Senses`) and read as a persona without it (a drives /// block, if any, lowers next); the digest is the whole document's, the block included -fn build_senses(doc: &Json) -> R { +#[inline(never)] +fn build_senses(doc: &Json) -> R> { let kv = doc.as_obj().unwrap(); let sl = match get(kv, "senses") { Some(Json::Arr(a)) if (1..=24).contains(&a.len()) => a, _ => return Err(perr("senses", "a list of 1 to 24 senses")) }; let mut senses: Vec = vec![]; @@ -1932,7 +1962,7 @@ fn build_senses(doc: &Json) -> R { let ii = slot(&mut d2, "inputs"); d2[ii].1 = Json::Arr(ins); // an error in a synthetic input is reported at the senses path it comes from // a drives block lowers next, with the senses at hand (a need may grow per reading of a number sense, RFC 0002 A.1) - let lowered = if get(&d2, "drives").is_some() { build_drives(&Json::Obj(d2), &senses) } else { build(&Json::Obj(d2)) }; + let lowered = if get(&d2, "drives").is_some() { build_drives(&Json::Obj(d2), &senses) } else { build_boxed(&Json::Obj(d2)) }; let mut p = lowered.map_err(|e| { for (j, o) in origins.iter().enumerate() { let pre = format!("inputs[{}]", n_decl + j); if let Some(rest) = e.path.strip_prefix(&pre).filter(|r| r.is_empty() || r.starts_with('.') || r.starts_with('[')) { return perr(&format!("{o}{}", rest.strip_prefix(".effects").unwrap_or("")), e.msg); } } e })?; p.digest = sha(doc); @@ -2915,6 +2945,9 @@ pub fn deficits(p: &Persona, st: &State) -> Vec<(String, f64, f64)> { } /// 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). +/// `init` on the heap (the setup path of a small-stack target: docs/embedding.md "Stack budget") +#[inline(never)] +pub fn init_boxed(p: &Persona, seed: Option, rest: bool, eng: Engine) -> Box { Box::new(init(p, seed, rest, eng)) } 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), @@ -4059,6 +4092,46 @@ mod tests { fn inputs(pr: bool, cr: bool, loss: bool) -> Json { json::parse(&format!(r#"{{"praise":{pr},"criticism":{cr},"loss":{loss}}}"#)).unwrap() } fn err(r: R) -> String { r.err().map(|e| format!("{}: {}", e.path, e.msg)).unwrap_or_default() } + /// The streamed sha256 is the one-shot one at every length around the 64-byte block and padding edges, fed in any pieces + #[test] + fn sha256_fed_in_pieces_is_the_one_shot_digest() { + assert_eq!(hex(&sha256(b"")), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); + assert_eq!(hex(&sha256(b"abc")), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + let data: Vec = (0..300u32).map(|i| (i * 7 + 3) as u8).collect(); + for n in [0, 1, 55, 56, 57, 63, 64, 65, 119, 120, 128, 300] { let one = sha256(&data[..n]); + for piece in [1, 3, 64, 100] { let mut h = Sha256::new(); for c in data[..n].chunks(piece) { h.update(c); } assert_eq!(h.finish(), one, "{n} bytes in pieces of {piece}"); } } + } + /// The boxed setup API is the by-value one: the same persona, state, header and individual + #[test] + fn the_boxed_setup_is_the_by_value_setup() { + use crate::strand::{self, Clock, Live}; + let doc = json::parse(&DOC.replace("LEARN", LEARN)).unwrap(); + let (p, b) = (build(&doc).unwrap(), build_boxed(&doc).unwrap()); assert_eq!(p.digest, b.digest); + let (st, sb) = (init(&p, Some(4), true, &run), init_boxed(&b, Some(4), true, &run)); assert_eq!(st.digest, sb.digest); + let (mut a, ha) = Live::start(p.clone(), &doc, st.clone(), Clock::Fixed, "probbit 0.14.0"); + let (mut c, hc) = Live::start_boxed(b, &doc, sb, Clock::Fixed, "probbit 0.14.0"); assert_eq!(ha, hc); assert_eq!(a.head(), c.head()); + let (_, la) = a.event(&inputs(true, false, false), &run).unwrap(); let (_, lc) = c.event(&inputs(true, false, false), &run).unwrap(); assert_eq!(la, lc); + let text = format!("{ha}\n{la}\n"); + let r1 = strand::resume(&text, &p, a.st.clone(), Clock::Fixed).unwrap(); let r2 = strand::resume_boxed(&text, &p, Box::new(c.st.clone()), Clock::Fixed).unwrap(); + assert_eq!((r1.n, r1.head(), &r1.st.digest), (r2.n, r2.head(), &r2.st.digest)); + } + /// The header written in pieces is `Live::start`'s header byte for byte, and its returned digest is the chain's first `prev`, + /// at every piece size (1 byte, a size that cuts inside UTF-8 characters, the default, one piece for the whole line) + #[test] + fn the_streamed_header_is_the_header_byte_for_byte() { + use crate::strand::{self, Clock, Live}; + // a document with non-ASCII text and escapes, and a display block (the header keeps the document as given) + let src = DOC.replace("LEARN", LEARN).replace(r#""name":"L""#, r#""name":"L","summary":"caf\u00e9 \u263a tab\t \"q\"""#); + let doc = json::parse(&src).unwrap(); let p = build(&doc).unwrap(); let st = init(&p, None, true, &run); + let (lv, header) = Live::start(p.clone(), &doc, st.clone(), Clock::Fixed, "probbit 0.14.0"); + for chunk in [0, 1, 7, 64, strand::HEADER_CHUNK, header.len(), header.len() + 9] { + let mut pieces: Vec> = vec![]; let d = strand::write_header(&p, &doc, &st, "probbit 0.14.0", chunk, &mut |c: &[u8]| pieces.push(c.to_vec())); + assert!(pieces.iter().all(|c| !c.is_empty() && c.len() <= chunk.max(1)), "piece size {chunk}"); + assert_eq!(pieces.concat(), header.as_bytes(), "piece size {chunk}"); assert_eq!(d, lv.head()); assert_eq!(d, digest_of(&header)); } + let mut out = vec![]; let lv2 = Live::start_to(Box::new(p), &doc, Box::new(st), Clock::Fixed, "probbit 0.14.0", 100, &mut |c: &[u8]| out.extend_from_slice(c)); + assert_eq!(out, header.as_bytes()); assert_eq!(lv2.head(), lv.head()); + } + /// Check both signs at the rounded cap and refusal beyond half a unit of the sixth decimal. #[test] fn state_cap_rounding_has_a_bounded_tolerance() { diff --git a/probbit-persona/src/strand.rs b/probbit-persona/src/strand.rs index dc76539..0d0dc56 100644 --- a/probbit-persona/src/strand.rs +++ b/probbit-persona/src/strand.rs @@ -76,11 +76,6 @@ fn dev_r6(l: &str) -> Option { for k in ["self", "pred", "input"] { if let Some(Json::Num(x)) = field(d, k) { *x = persona::r6(*x); } } } Some(persona::canon(&j)) } -/// A document with its keys sorted at every level (`persona::text` of it is its canonical JSON) -fn sorted(j: &Json) -> Json { - match j { Json::Obj(v) => { let mut o: Vec<(String, Json)> = v.iter().map(|(k, x)| (k.clone(), sorted(x))).collect(); o.sort_by(|a, b| a.0.cmp(&b.0)); Json::Obj(o) } - Json::Arr(v) => Json::Arr(v.iter().map(sorted).collect()), x => x.clone() } -} /// One line of a strand without its line ending: strands are written with `\n`; a `\r\n` copy (Windows text mode, an editor) /// reads the same, since a canonical JSON line never ends in `\r` itself fn bare(l: &str) -> &str { l.strip_suffix('\r').unwrap_or(l) } @@ -90,14 +85,31 @@ impl Live { /// state as canonical JSON (keys sorted), so a state read from a file and the same state from `init` give one header; the /// header is compact JSON, not key-sorted. pub fn start(p: Persona, doc: &Json, st: State, clock: Clock, engine: &str) -> (Live, String) { - let s = |x: &str| Json::Str(x.to_string()); - let header = persona::text(&Json::Obj(vec![("probbit_strand".into(), Json::Num(FORMAT)), ("engine".into(), s(engine)), - ("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)), ("state".into(), sorted(&st.to_json(&p))), ("document".into(), doc.clone())])); - let prev = persona::digest_of(&header); + let (lv, header) = Live::start_boxed(Box::new(p), doc, Box::new(st), clock, engine); (*lv, header) + } + /// `start` on the heap: the persona and the state come in boxed and the individual goes out boxed, so the setup path of a + /// small-stack target holds none of them by value (docs/embedding.md "Stack budget"). The header is `start`'s, byte for byte. + #[inline(never)] + pub fn start_boxed(p: Box, doc: &Json, st: Box, clock: Clock, engine: &str) -> (Box, String) { + let mut b = Vec::new(); + let lv = Live::start_to(p, doc, st, clock, engine, HEADER_CHUNK, &mut |c: &[u8]| b.extend_from_slice(c)); + // the pieces are the bytes of one UTF-8 text cut at fixed offsets: whole again, it is that text + (lv, String::from_utf8(b).unwrap_or_default()) + } + /// `start_boxed` with the header handed to `out` in pieces of at most `chunk` bytes (the last one shorter) instead of returned + /// whole: a host writes each piece to its store and may yield between pieces (a flash write of the whole line can outlast a + /// watchdog). The pieces end to end are `start`'s header (without its `\n`); a piece may end inside a UTF-8 character. + #[inline(never)] + pub fn start_to(p: Box, doc: &Json, st: Box, clock: Clock, engine: &str, chunk: usize, out: &mut dyn FnMut(&[u8])) -> Box { + let prev = write_header(&p, doc, &st, engine, chunk, out); let expect = p.expect.as_ref().map(|_| persona::ExpectMem::new()); let dev_full = engine_version(engine).is_some_and(|v| v < DEV_R6_SINCE); - (Live { p, st, clock, last: 0.0, prev, n: 0, status: Status::Active, controls: 0, checkpoints: 0, rests: 0, expect, traj: None, dev_full }, header) + Live::boxed(p, st, clock, prev, 0, expect, dev_full) + } + /// The individual on the heap from its parts (one place builds a `Live`: `start_to` and `resume_boxed`) + #[inline(never)] + fn boxed(p: Box, st: Box, clock: Clock, prev: String, n: u64, expect: Option, dev_full: bool) -> Box { + Box::new(Live { p: *p, st: *st, clock, last: 0.0, prev, n, status: Status::Active, controls: 0, checkpoints: 0, rests: 0, expect, traj: None, dev_full }) } /// One event: stamp its elapsed hours, run the turn, chain the strand line -> (the stance document, the strand line). A bad /// event is an error and changes nothing (no turn, no line); so is every event while the individual is paused or retired. @@ -154,6 +166,40 @@ impl Live { /// The sha256 of the strand's last line (the header's before any event) pub fn head(&self) -> &str { &self.prev } } +/// The header piece size `start_boxed` collects with (any size gives the same bytes) +pub const HEADER_CHUNK: usize = 1024; +/// A sink that cuts the text into pieces of `cap` bytes for `out` and hashes it on the way +struct Pieces<'a> { buf: Vec, cap: usize, out: &'a mut dyn FnMut(&[u8]), h: persona::Sha256 } +impl Pieces<'_> { + fn put(&mut self, mut b: &[u8]) { + self.h.update(b); + while !b.is_empty() { let k = (self.cap - self.buf.len()).min(b.len()); self.buf.extend_from_slice(&b[..k]); b = &b[k..]; + if self.buf.len() == self.cap { (self.out)(&self.buf); self.buf.clear(); } } + } +} +impl persona::Sink for Pieces<'_> { + fn push(&mut self, c: char) { let mut t = [0u8; 4]; self.put(c.encode_utf8(&mut t).as_bytes()) } + fn push_str(&mut self, t: &str) { self.put(t.as_bytes()) } +} +/// The strand's header line for `p`, `doc`, `st` and `engine` (`Live::start`'s, without its `\n`), handed to `out` in pieces of +/// at most `chunk` bytes (a 0 is taken as 1) -> its sha256 (`sha256:` + hex), the first line's `prev`. Neither the line nor a +/// copy of the document is held whole: the buffer is one piece. +pub fn write_header(p: &Persona, doc: &Json, st: &State, engine: &str, chunk: usize, out: &mut dyn FnMut(&[u8])) -> String { + use persona::Sink; + let cap = chunk.max(1); + let mut w = Pieces { buf: Vec::with_capacity(cap), cap, out, h: persona::Sha256::new() }; + let s = |x: &str| Json::Str(x.to_string()); + // the keys in `Live::start`'s order; values through the canonical writer (the state with its keys sorted, the rest as given) + w.push_str("{\"probbit_strand\":"); persona::write_to(&Json::Num(FORMAT), false, &mut w); + w.push_str(",\"engine\":"); persona::write_to(&s(engine), false, &mut w); + w.push_str(",\"persona\":"); persona::write_to(&Json::Obj(vec![("name".into(), s(&p.name)), ("version".into(), s(&p.version)), ("digest".into(), s(&p.digest))]), false, &mut w); + w.push_str(",\"seed\":"); persona::write_to(&Json::Num(st.seed as f64), false, &mut w); + w.push_str(",\"state\":"); persona::write_to(&st.to_json(p), true, &mut w); + w.push_str(",\"document\":"); persona::write_to(doc, false, &mut w); + w.push('}'); + if !w.buf.is_empty() { (w.out)(&w.buf); } + format!("sha256:{}", persona::hex(&w.h.finish())) +} /// A control line after the line whose sha256 is `prev` (canonical JSON: at, by, control, prev, reason). `by` names who: a /// person or a sensor, never the individual or the clock; the reason is 1-500 characters. pub fn control_line(prev: &str, what: &str, by: &str, reason: &str, at: &str) -> Result { @@ -563,14 +609,17 @@ pub fn check_chain(text: &str) -> Result { /// Continue a strand: `text` is the strand so far and `st` the individual after its last line -> the individual, ready for the /// next event (no header to write). The header's persona must be `p` (the same digest); the run uses the header's document, the /// key order the strand replays with. The lines before are not replayed here (`verify` does that). -pub fn resume(text: &str, p: &Persona, st: State, clock: Clock) -> Result { +pub fn resume(text: &str, p: &Persona, st: State, clock: Clock) -> Result { resume_boxed(text, p, Box::new(st), clock).map(|lv| *lv) } +/// `resume` on the heap: the state comes in boxed and the individual goes out boxed (docs/embedding.md "Stack budget") +#[inline(never)] +pub fn resume_boxed(text: &str, p: &Persona, st: Box, clock: Clock) -> Result, InErr> { let bad = |m: String| perr("strand", m); if !text.ends_with('\n') { return Err(bad("its last line is incomplete (no newline at the end)".into())); } check_chain(text)?; let lines = lines(text); let h = json::parse(lines.first().copied().unwrap_or("")).map_err(|e| bad(format!("the header is not JSON: {}", e.msg)))?; if h.get("probbit_strand").and_then(Json::as_f64) != Some(FORMAT) { return Err(bad("not a probbit strand (format 1)".into())); } - let q = persona::build(h.get("document").unwrap_or(&Json::Null)).map_err(|e| bad(format!("the header's persona: {}: {}", e.path, e.msg)))?; + let q = persona::build_boxed(h.get("document").unwrap_or(&Json::Null)).map_err(|e| bad(format!("the header's persona: {}: {}", e.path, e.msg)))?; if q.digest != p.digest { return Err(bad(format!("it is another persona's ({} != {})", &q.digest[..19], &p.digest[..19]))); } let last = lines[lines.len() - 1]; // the control lines after the last event or checkpoint line (the anchor; the header without either) set the status: an @@ -588,8 +637,8 @@ pub fn resume(text: &str, p: &Persona, st: State, clock: Clock) -> Result(line: usize, msg: impl Into) -> Result { Err(YamlError { line, msg: msg.into() }) } @@ -133,7 +133,7 @@ fn scalar(tok: &str, ln: usize) -> Result { /// One-line flow collection `[...]` / `{...}` fn flow(t: &[char], ln: usize) -> Result { - struct F<'a> { t: &'a [char], pos: usize, ln: usize } + struct F<'a> { t: &'a [char], pos: usize, ln: usize, d: usize } impl F<'_> { fn ws(&mut self) { while self.pos < self.t.len() && self.t[self.pos] == ' ' { self.pos += 1; } } /// (token, quoted) @@ -147,7 +147,13 @@ fn flow(t: &[char], ln: usize) -> Result { 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)) } + /// A value; a collection more than `MAX_DEPTH` deep is an error (the recursion's stack is bounded) fn value(&mut self) -> Result { + self.ws(); let open = matches!(self.t.get(self.pos), Some('[' | '{')); + if open { if self.d >= MAX_DEPTH { return err(self.ln, format!("limit: flow collections nested deeper than {MAX_DEPTH}")); } self.d += 1; } + let v = self.item(); if open { self.d -= 1; } v + } + fn item(&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(); @@ -167,7 +173,7 @@ fn flow(t: &[char], ln: usize) -> Result { _ => { 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(); + let mut f = F { t, pos: 0, ln, d: 0 }; 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) } @@ -198,17 +204,20 @@ fn split_key(s: &str, ln: usize, item: bool) -> Result, 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> { +/// One block collection at `indent`, `d` collections deep (the document's own is 0): deeper than `MAX_DEPTH` is an error, so the +/// recursion's stack is bounded (a document indented one more space per line used to recurse once per line) +fn block(rows: &mut Vec<(usize, String, usize)>, mut i: usize, indent: usize, d: usize) -> Result<(Json, usize), YamlError> { + if d >= MAX_DEPTH { return err(rows[i].2, format!("limit: collections nested deeper than {MAX_DEPTH}")); } 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 }; + let v = if i + 1 < rows.len() && rows[i + 1].0 > indent { let (v, n) = block(rows, i + 1, rows[i + 1].0, d + 1)?; 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); } + if key.is_some() { rows[i] = (col, inner, ln); let (v, n) = block(rows, i, col, d + 1)?; 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"); } @@ -222,9 +231,9 @@ fn block(rows: &mut Vec<(usize, String, usize)>, mut i: usize, indent: usize) -> 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 } + else if i + 1 < rows.len() && rows[i + 1].0 > indent { let (v, n) = block(rows, i + 1, rows[i + 1].0, d + 1)?; 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 if i + 1 < rows.len() && rows[i + 1].0 == indent && is_item(&rows[i + 1].1) { let (v, n) = block(rows, i + 1, indent, d + 1)?; i = n; v } else { i += 1; Json::Null }; out.push((k, v)); } @@ -237,7 +246,26 @@ 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)?; + let base = rows[0].0; let (v, i) = block(&mut rows, 0, base, 0)?; if i != rows.len() { return err(rows[i].2, "unexpected text (indentation?)"); } Ok(v) } + +#[cfg(test)] +mod tests { + use super::*; + /// Block collections: `MAX_DEPTH` deep is read, one more is a `limit` error (the recursion's stack is bounded) + #[test] + fn block_nesting_is_bounded() { + let doc = |n: usize| { let mut t = String::new(); for k in 0..n { t.push_str(&" ".repeat(k)); t.push_str(if k + 1 == n { "k: 1\n" } else { "k:\n" }); } t }; + let mut v = load(&doc(MAX_DEPTH)).unwrap_or_else(|e| panic!("{}", e.msg)); let mut d = 0; while let Some(x) = v.get("k") { v = x.clone(); d += 1; } assert_eq!(d, MAX_DEPTH); + let e = load(&doc(MAX_DEPTH + 1)).err().unwrap(); assert!(e.msg.starts_with("limit: ") && e.msg.contains("256"), "{}", e.msg); assert_eq!(e.line, MAX_DEPTH + 1); + } + /// Flow collections: `MAX_DEPTH` deep is read, one more is a `limit` error + #[test] + fn flow_nesting_is_bounded() { + let doc = |n: usize| format!("k: {}1{}\n", "[".repeat(n), "]".repeat(n)); + assert!(load(&doc(MAX_DEPTH)).is_ok()); + let e = load(&doc(MAX_DEPTH + 1)).err().unwrap(); assert!(e.msg.starts_with("limit: ") && e.msg.contains("256"), "{}", e.msg); + } +} From 66eb3cdf85b7c5477862adff549136c7cbb4f863 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 00:24:20 -0700 Subject: [PATCH 06/61] probbit-persona: Live is written in place into its box (no frame holds a Live); docs: the embedding guide's stack budget and the boxed setup path Live::boxed copied a Live built on the stack into the box (a 1.7 KB frame on Xtensa); it now writes each field through a raw field pointer, the persona and the state copied from their boxes, and an exhaustive pattern fails to compile when a field is added without being written. docs/embedding.md gains the boxed calls, the header written in pieces, and a per-call table from a host proxy (stack high-water, heap peak) and Xtensa frame sizes; CHANGELOG Unreleased. --- CHANGELOG.md | 8 ++++++ docs/embedding.md | 50 ++++++++++++++++++++++++++++++++++- probbit-persona/src/strand.rs | 22 +++++++++++++-- 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7706256..11c4b58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ All notable changes to this project are documented here. The format follows checkpoint). Gated on the strand header's engine: a strand written before 0.14.0 keeps its full-precision deviation, and `live verify` accepts its checkpoint when it equals the replay's at 6 decimals; strands without an expectation block are unchanged. +- **The setup path fits a small stack** (docs/embedding.md "Stack budget"): heap-returning setup calls + `persona::build_boxed` (`Box`), `persona::init_boxed` (`Box`), `Live::start_boxed` (`Box` and the header), + `Live::start_to` (the header handed to the host in fixed-size pieces, so a firmware can write it to flash piece by piece with + yields) and `strand::resume_boxed`; `strand::write_header` writes the header line in pieces and returns its sha256. The persona + reader no longer holds a `Persona` by value at each of its layers, and its readers are out of line. On a host proxy of a firmware's setup (a 12 KB persona, `nostd`, x86_64) the stack high-water of parse + build + init + start fell from 39,056 to 12,112 bytes; on Xtensa the reader's frame went from 3,312 to 400 bytes and `Live::start`'s from 1,888 to 160. The by-value + calls are unchanged wrappers; every header, line and digest is byte for byte as before. `persona::sha256` hashes without + copying its input (`persona::Sha256`, fed in pieces). The YAML reader refuses collections nested deeper than 256 (a `limit` + error, as the JSON reader does) instead of recursing without a bound. ### Fixed diff --git a/docs/embedding.md b/docs/embedding.md index 7dbf146..b531db4 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -48,10 +48,58 @@ What changes without an operating system: thread, as probbit-wasm runs. At fixed work the documents are the ones the CLI writes at any `--threads` (timings and `telemetry.threads` aside). Set `probbit_ir::GATE_THREADS` to 1 as probbit-wasm does. One core calls the engine. - **Documents arrive as strings.** There is no file system: `persona::load(path)` returns an error. Read the document with - `persona::parse_doc(text, is_json)` and build it with `persona::build(&doc)`. + `persona::parse_doc(text, is_json)` and build it with `persona::build(&doc)` (or `build_boxed`, below). - **No usage telemetry.** `sys::usage()` and `sys::nice()` return `None`, so `process_cpu_ms`, `peak_rss_mb` and `nice` are `null` in telemetry. +## Stack budget: the boxed setup path + +A small target runs the engine on a thread with a fixed stack (tens of KB). The by-value setup calls move `Persona` (about +1.5 KB), `State` (about 1 KB) and `Live` through their frames, and the persona reader recursed through itself once per layer of +the document (`display`, `bound`, `expectation`, the lowered blocks) with all of its readers inlined into that frame. The +heap-returning calls keep each of them on the heap from the moment it is built: + +```rust +use probbit_persona::{persona, strand::{self, Clock, Live}}; +let doc = persona::parse_doc(TEXT, false)?; // Json: a 32-byte value; the tree is on the heap +let p = persona::build_boxed(&doc)?; // Box +let st = persona::init_boxed(&p, None, true, &eng); // Box +// a fresh strand: the header goes to `out` in pieces of at most 1 KB (write each, yield between them), not as one String +let live = Live::start_to(p, &doc, st, Clock::Real(probbit_core::rt::Instant::now()), &strand::engine(), 1024, &mut out); +// or: let (live, header) = Live::start_boxed(p, &doc, st, clock, &engine); // Box and the header whole +// a stored strand: let live = strand::resume_boxed(&text, &p, st, clock)?; // Box +``` + +`build`, `init`, `Live::start` and `resume` stay as they were (thin wrappers over the boxed calls) for hosts with a large stack. +The header the pieces make is `Live::start`'s header byte for byte, and `strand::write_header` returns its sha256 (the first +line's `prev`), so the chain is the same however the host cuts it; neither the line nor a copy of the document is held whole. +The YAML reader is bounded like the JSON one: block and flow collections nested deeper than 256 are a `limit` error (the JSON +reader already refused them); on a stack of tens of KB keep documents a few levels deep, as every example is. + +The stack each call needs, measured by a host proxy: probbit-persona `nostd` on x86_64 (release, `lto = "fat"`), each call +run from one frame on a thread whose free stack is painted with a pattern first and scanned after (the high-water below the +calling frame), and a counting allocator for the heap peak above what was live before the call. The persona is a 12 KB body +persona (traits, moods, senses, drives, needs, expectation, display; its header line is 11,985 bytes), read from YAML. + +| call (before → after) | Xtensa frame, B | stack high-water, B | heap peak, B | +|---|---:|---:|---:| +| `parse_doc` (YAML) | 128 → 128 | 4,760 → 5,048 | 93,411 → 93,411 | +| `build` → `build_boxed` | 3,312 → 400 (its reader `build_base` 1,280) | **39,024 → 12,080** | 267,736 → 269,232 | +| `init` → `init_boxed` | 1,184 → 624 (+ `init` 1,184) | 9,240 → 9,240 | 69,365 → 69,365 | +| `Live::start` → `Live::start_to` / `start_boxed` | 1,888 → 160 / 96 (`write_header` 448) | 9,872 → 4,560 | 87,482 → 19,181 | +| **setup: parse + build + init + start** | | **39,056 → 12,112** | 332,669 → 334,165 | +| `resume` → `resume_boxed` | 4,880 → 960 | 55,328 → 29,264 | 397,428 → 398,924 | +| one event (`Live::event`) | 2,688 → 2,672 (`turn_src` 6,160) | 31,904 → 31,824 | 112,990 → 112,990 | +| the solve alone (in the event) | | 6,856 | 29,009 | +| a checkpoint line | 160 | 5,408 | 42,594 | + +The setup now needs less stack than one event: the engine thread's stack is sized by the turn (about 32 KB on the proxy; +`turn_src` holds several `State` values by value), the next thing to cut. The heap peak of setup is the reader's (the +document tree and the lowered documents); `start` no longer holds a copy of the document and the whole line. + +Silicon numbers (the same calls on the target's engine thread) are measured by the firmware and will follow; the x86_64 proxy +and an Xtensa build differ in frame layout, so read the proxy as the shape and the ratio, not the target's bytes. + ## The strand writer, and how the host checks it `probbit_persona::strand` has no file I/O: each call returns the line, and the caller stores it (the CLI appends to a file under diff --git a/probbit-persona/src/strand.rs b/probbit-persona/src/strand.rs index 0d0dc56..bd87aea 100644 --- a/probbit-persona/src/strand.rs +++ b/probbit-persona/src/strand.rs @@ -106,10 +106,28 @@ impl Live { let dev_full = engine_version(engine).is_some_and(|v| v < DEV_R6_SINCE); Live::boxed(p, st, clock, prev, 0, expect, dev_full) } - /// The individual on the heap from its parts (one place builds a `Live`: `start_to` and `resume_boxed`) + /// The individual on the heap from its parts (one place builds a `Live`: `start_to` and `resume_boxed`), written in place: + /// the persona and the state are copied from their boxes into the new one, so no frame holds a `Live` (`Box::new` of the + /// value built it on the stack first: a 1.7 KB frame on Xtensa) #[inline(never)] fn boxed(p: Box, st: Box, clock: Clock, prev: String, n: u64, expect: Option, dev_full: bool) -> Box { - Box::new(Live { p: *p, st: *st, clock, last: 0.0, prev, n, status: Status::Active, controls: 0, checkpoints: 0, rests: 0, expect, traj: None, dev_full }) + use core::mem::MaybeUninit; use core::ptr::{addr_of_mut, copy_nonoverlapping}; + // every field is written below: this pattern stops compiling when a field is added and not written + let _all = |l: &Live| { let Live { p: _, st: _, clock: _, last: _, prev: _, n: _, status: _, controls: _, checkpoints: _, rests: _, expect: _, traj: _, dev_full: _ } = l; }; + let mut b: Box> = Box::new(MaybeUninit::uninit()); + let l = b.as_mut_ptr(); + // SAFETY: `l` points to an allocation for one `Live`; each field is written once, through raw field pointers (no reference + // to the uninitialised value); the persona and the state are moved by a bitwise copy out of their boxes, which are then + // freed as `MaybeUninit` (their contents are not dropped: they live on in the new box) + unsafe { + copy_nonoverlapping(&*p as *const Persona, addr_of_mut!((*l).p), 1); + copy_nonoverlapping(&*st as *const State, addr_of_mut!((*l).st), 1); + drop(Box::from_raw(Box::into_raw(p) as *mut MaybeUninit)); drop(Box::from_raw(Box::into_raw(st) as *mut MaybeUninit)); + addr_of_mut!((*l).clock).write(clock); addr_of_mut!((*l).last).write(0.0); addr_of_mut!((*l).prev).write(prev); addr_of_mut!((*l).n).write(n); + addr_of_mut!((*l).status).write(Status::Active); addr_of_mut!((*l).controls).write(0); addr_of_mut!((*l).checkpoints).write(0); addr_of_mut!((*l).rests).write(0); + addr_of_mut!((*l).expect).write(expect); addr_of_mut!((*l).traj).write(None); addr_of_mut!((*l).dev_full).write(dev_full); + Box::from_raw(Box::into_raw(b) as *mut Live) + } } /// One event: stamp its elapsed hours, run the turn, chain the strand line -> (the stance document, the strand line). A bad /// event is an error and changes nothing (no turn, no line); so is every event while the individual is paused or retired. From 55f2c7d47302161a85678aa47ec18a18f03f713e Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 01:07:47 -0700 Subject: [PATCH 07/61] probbit-persona: turn_trace (feature, off by default): one event's microseconds and a host probe's bytes by phase (input, expect, prep, compile, solve, decode, document, line, fold); probbit live prints them on stderr with PROBBIT_TURN_TRACE in a turn_trace build --- probbit-cli/Cargo.toml | 4 ++ probbit-cli/src/main.rs | 13 +++++ probbit-persona/Cargo.toml | 2 + probbit-persona/src/lib.rs | 5 +- probbit-persona/src/persona.rs | 13 +++-- probbit-persona/src/strand.rs | 9 +++- probbit-persona/src/trace.rs | 94 ++++++++++++++++++++++++++++++++++ 7 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 probbit-persona/src/trace.rs diff --git a/probbit-cli/Cargo.toml b/probbit-cli/Cargo.toml index 630dd23..a00b6e6 100644 --- a/probbit-cli/Cargo.toml +++ b/probbit-cli/Cargo.toml @@ -14,6 +14,10 @@ categories = ["command-line-utilities", "algorithms", "science"] name = "probbit" path = "src/main.rs" +[features] +# `probbit live` with PROBBIT_TURN_TRACE set prints each event's phases to stderr (probbit-persona `turn_trace`) +turn_trace = ["probbit-persona/turn_trace"] + [dependencies] probbit-core = { path = "../probbit-core", version = "0.13.0" } probbit-decide = { path = "../probbit-decide", version = "0.13.0" } diff --git a/probbit-cli/src/main.rs b/probbit-cli/src/main.rs index d8a798e..e3b60b4 100644 --- a/probbit-cli/src/main.rs +++ b/probbit-cli/src/main.rs @@ -874,8 +874,15 @@ fn live_cmd(args: &[String]) { live::append(f, ls).unwrap_or_else(|e| fail(&format!("live: {}", e.msg))) }; if let Some(h) = &header { log(&[h]); } let (mut bad, mut refused, mut warned, mut warned_credit, mut warned_needs) = (0, 0, false, false, false); + // feature `turn_trace`: with PROBBIT_TURN_TRACE set, each event's phases on stderr (the parse and the append are timed here) + #[cfg(feature = "turn_trace")] + let trace_on = std::env::var_os("PROBBIT_TURN_TRACE").is_some(); let mut one = |lv: &mut live::Live, i: usize, t: &str| { + #[cfg(feature = "turn_trace")] + let (t_parse, parse_us) = (std::time::Instant::now(), std::cell::Cell::new(0u64)); let res = json::parse(t).map_err(|e| persona::perr("event", format!("not JSON: {}", e.msg))).and_then(|ev| { + #[cfg(feature = "turn_trace")] + parse_us.set(t_parse.elapsed().as_micros() as u64); // readings sent to a persona without a senses block are an undeclared input (listed in `ignored`): say so once if !warned && !lv.p.has_senses() && ev.get("senses").is_some() { warned = true; err_line(&format!("probbit: live: events[{i}]: this persona has no senses block: `senses` is an undeclared input (listed in ignored), no reading is judged")); } @@ -888,7 +895,13 @@ fn live_cmd(args: &[String]) { lv.event(&ev, eng) }); match res { Ok((stance, line)) => { let cp = (strand.is_some() && every > 0 && lv.n % every == 0).then(|| lv.checkpoint(&stance)); + #[cfg(feature = "turn_trace")] + let t_append = std::time::Instant::now(); match &cp { Some(c) => log(&[&line, c]), None => log(&[&line]) } + #[cfg(feature = "turn_trace")] + if trace_on { let tr = probbit_persona::trace::last(); let us = |t: std::time::Instant| Json::Num(t.elapsed().as_micros() as f64); + let mut j = tr.to_json(); if let Json::Obj(v) = &mut j { v.push(("n".into(), Json::Num(lv.n as f64))); v.push(("parse_us".into(), Json::Num(parse_us.get() as f64))); v.push(("append_us".into(), us(t_append))); } + err_line(&format!("probbit: live: trace {}", persona::canon(&j))); } if let Some(f) = &state_file { put(Some(f), &persona::canon(&lv.st.to_json(&lv.p))); } emit(&persona::canon(&stance)); } Err(e) => { if ["paused", "retired"].contains(&e.code) { refused += 1 } else { bad += 1 } let e = json::InErr { path: format!("events[{i}].{}", e.path), ..e }; err_line(&format!("probbit: live: {}: {}", e.path, e.msg)); error_object(&e.to_json()); } } }; diff --git a/probbit-persona/Cargo.toml b/probbit-persona/Cargo.toml index 84dd2b8..142b857 100644 --- a/probbit-persona/Cargo.toml +++ b/probbit-persona/Cargo.toml @@ -17,6 +17,8 @@ std = ["probbit-core/std", "probbit-ir/std", "probbit-decide?/std"] nostd = ["dep:probbit-nostd", "probbit-core/nostd", "probbit-ir/nostd", "probbit-decide?/nostd"] # `router` (probbit decide) and `evaluate` (the decision-API adapter): probbit-decide decide = ["dep:probbit-decide"] +# `turn_trace` (off by default): per-phase microseconds and a host probe's bytes for the last `Live::event` (`persona::trace`) +turn_trace = [] [dependencies] probbit-nostd = { path = "../probbit-nostd", version = "0.13.0", optional = true } diff --git a/probbit-persona/src/lib.rs b/probbit-persona/src/lib.rs index 45c0018..9199a5e 100644 --- a/probbit-persona/src/lib.rs +++ b/probbit-persona/src/lib.rs @@ -6,10 +6,12 @@ //! `evaluate` (feature `decide`): the decision-API adapter. //! - `strand`: the strand writer (`Live`: start, event, control, checkpoint and rest lines, the `prev` chain) and its checks over //! a `&str` (`Replay`, `verify*`, `read_actions`, `first_difference`), with no file I/O. +//! - `trace`: with feature `turn_trace`, one event's time and a host probe's bytes by phase (`Live::event`). //! - `sys`: the process's resource usage (getrusage on 64-bit Unix; `None` elsewhere). `hooks`: the host's monitor and config echo. //! //! Features: `std` (default) builds on std; `nostd` builds `no_std` + `alloc` for a target without an operating system, with -//! the std subset from probbit-nostd (docs/embedding.md). `decide` (default) adds `router` and `evaluate` (probbit-decide). +//! the std subset from probbit-nostd (docs/embedding.md). `decide` (default) adds `router` and `evaluate` (probbit-decide). `turn_trace` (off) records the +//! last event's phases. #![cfg_attr(not(feature = "std"), no_std)] #[cfg(not(feature = "std"))] #[allow(unused_imports)] @@ -32,4 +34,5 @@ pub mod router; pub mod run; pub mod strand; pub mod sys; +pub mod trace; pub mod yaml; diff --git a/probbit-persona/src/persona.rs b/probbit-persona/src/persona.rs index fd765b0..9e9f39c 100644 --- a/probbit-persona/src/persona.rs +++ b/probbit-persona/src/persona.rs @@ -2827,7 +2827,9 @@ fn decode(p: &Persona, st: &State, program: &str, meta: &Meta, doc: &Json, confl } // ------------------------------------------------------------------------------------------------------------ turns -fn solve(p: &Persona, st: &State, prog: &Json, eng: Engine, calls: &mut usize) -> Json { *calls += 1; eng(prog, &flags(p, st)) } +fn solve(p: &Persona, st: &State, prog: &Json, eng: Engine, calls: &mut usize) -> Json { + *calls += 1; let was = crate::trace::enter(crate::trace::Phase::Solve); let r = eng(prog, &flags(p, st)); crate::trace::enter(was); r +} 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 @@ -2847,6 +2849,8 @@ pub fn turn(p: &Persona, st: &State, raw: &Json, no_inertia: bool, eng: Engine, /// carry no `src` and stand for events of an accepted source pub fn turn_any_source(p: &Persona, st: &State, raw: &Json, no_inertia: bool, eng: Engine, timing: bool) -> R<(Json, State)> { turn_src(p, st, raw, no_inertia, eng, timing, false) } fn turn_src(p: &Persona, st: &State, raw: &Json, no_inertia: bool, eng: Engine, timing: bool, check: bool) -> R<(Json, State)> { + use crate::trace::{enter, Phase}; + enter(Phase::Prep); 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")); } } // reward provenance (§2.10), first: with `reward_from` (or a senses block, §2.11), `src` is read here (not compiled, so not @@ -2864,6 +2868,7 @@ fn turn_src(p: &Persona, st: &State, raw: &Json, no_inertia: bool, eng: Engine, // RFC 0002 Design B: the surprise gate steps v, r̂, g and the counts of each paid learned trait before the learner reads g let st2 = gate_step(p, st2, &raw)?; let st = &st2; + enter(Phase::Compile); 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)); @@ -2874,19 +2879,20 @@ fn turn_src(p: &Persona, st: &State, raw: &Json, no_inertia: bool, eng: Engine, 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)); + enter(Phase::Compile); 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); + enter(Phase::Decode); let (mut out, mut ns) = decode(p, st, &ptext, &meta, &doc, conflict.as_deref(), yielded.as_deref()); let lift = out.lift.clone(); // 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)?; + enter(Phase::Compile); let o2 = Opts { no_inertia, clamps: holds.clone(), ..Default::default() }; let (prog2, meta2) = compile(p, st, &raw, &o2)?; enter(Phase::Decode); 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); @@ -2898,6 +2904,7 @@ fn turn_src(p: &Persona, st: &State, raw: &Json, no_inertia: bool, eng: Engine, 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; out.lift = lift; + enter(Phase::Document); 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)))])); } diff --git a/probbit-persona/src/strand.rs b/probbit-persona/src/strand.rs index bd87aea..b0df804 100644 --- a/probbit-persona/src/strand.rs +++ b/probbit-persona/src/strand.rs @@ -132,6 +132,8 @@ impl Live { /// One event: stamp its elapsed hours, run the turn, chain the strand line -> (the stance document, the strand line). A bad /// event is an error and changes nothing (no turn, no line); so is every event while the individual is paused or retired. pub fn event(&mut self, ev: &Json, eng: Engine) -> Result<(Json, String), InErr> { + use crate::trace::{enter, Phase}; + crate::trace::begin(); if self.status != Status::Active { return Err(refusal(self.status)); } let Json::Obj(kv) = ev else { return Err(perr("event", "must be a JSON object of inputs")) }; let mut kv: Vec<(String, Json)> = kv.iter().filter(|(k, _)| k != "elapsed_hours").cloned().collect(); @@ -142,6 +144,7 @@ impl Live { Clock::Fixed => { let e = match given { None => 0.0, Some(Json::Num(x)) if x.is_finite() && *x >= 0.0 => *x, _ => return Err(perr("event.elapsed_hours", "a number >= 0")) }; (q6(e), self.last) } }; kv.push(("elapsed_hours".into(), Json::Num(elapsed))); let inputs = Json::Obj(kv); + enter(Phase::Expect); // RFC 0009: the deviation and the trajectory are read from the state before the event (effects {}: the turn is as without them) let dev = self.expect.as_ref().and_then(|m| persona::deviation(&self.p, &self.st, &inputs, m, eng)); let traj = persona::trajectory(&self.p, &self.st, &inputs); @@ -151,6 +154,7 @@ impl Live { // §2.18: the glyph reads the finished document, `deviation` included (the surprise modifier) persona::set_glyph(&self.p, &mut stance); // RFC 0002 part 2: a rest line due before this event is its own line, chained before the event line (the turn applied it) + enter(Phase::Line); let (rest, prev) = match persona::rest_line(&self.p, &self.st, &inputs) { Some((r, rs)) => { let l = rest_line(&self.prev, r, &rs.digest); let d = persona::digest_of(&l); (Some(l), d) } None => (None, self.prev.clone()) }; @@ -159,10 +163,13 @@ impl Live { if let Some(d) = &dev { lj.push(("deviation".into(), d.receipt())); } if let Some(g) = stance.get("glyph") { lj.push(("glyph".into(), g.clone())); } let line = persona::canon(&Json::Obj(lj)); + enter(Phase::Fold); if let Some(m) = self.expect.as_mut() { persona::expect_step(&self.p, m, &inputs, dev.as_ref().and_then(persona::Deviation::logged_pred)); } self.traj = traj; + enter(Phase::Line); self.n += 1; self.last = now; self.prev = persona::digest_of(&line); self.st = ns; - if let Some(r) = &rest { self.rests += 1; return Ok((stance, format!("{r}\n{line}"))); } + let line = match &rest { Some(r) => { self.rests += 1; format!("{r}\n{line}") } None => line }; + crate::trace::end(); Ok((stance, line)) } /// A control line (pause, resume or retire) `by` a person or a sensor (`human[:id]`, `env[:id]`) for `reason`, stamped `at`: diff --git a/probbit-persona/src/trace.rs b/probbit-persona/src/trace.rs new file mode 100644 index 0000000..0ceea0e --- /dev/null +++ b/probbit-persona/src/trace.rs @@ -0,0 +1,94 @@ +//! `turn_trace` (feature, off by default): where one event's time and bytes go, by phase, so a host measures the same breakdown +//! on its own hardware (docs/embedding.md "The turn's phases"). With the feature off every hook is an empty inline function. +//! +//! `Live::event` opens a trace and closes it on its way out; between, each phase is a segment of the installed clock +//! (`probbit_core::rt` on `nostd`). After the event `last()` holds per phase the microseconds spent in it and, when the host +//! installed a probe (`set_probe`), the largest value the probe returned at the end of one of its segments: a host reads its own +//! counter there (its allocator's peak since the last call, the stack's high-water). Turns outside `Live::event` are not traced. +//! The parse of the event line and the append of the strand line are the host's, before and after the call. +#[allow(unused_imports)] +use std::prelude::rust_2021::*; + +/// A phase of one event +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Phase { + /// the event's inputs read and stamped (elapsed hours) + Input = 0, + /// RFC 0009: the deviation and the trajectory (exact solves on the state before the event) + Expect = 1, + /// the steps before the compiler: provenance, payments, rest, senses / needs / drives / pace, credit, the surprise gate + Prep = 2, + /// the persona's program for the event (and for a yield or a hold) + Compile = 3, + /// the engine's calls + Solve = 4, + /// the engine's answer read back into a stance and the next state + Decode = 5, + /// the stance document: its blocks, credit, glyph and digest + Document = 6, + /// the strand line (and a rest line due before it) and its digest + Line = 7, + /// RFC 0009: the expectation's memory after the event + Fold = 8, +} +/// The number of phases +pub const PHASES: usize = 9; +/// The phases' names, in `Phase` order +pub const NAMES: [&str; PHASES] = ["input", "expect", "prep", "compile", "solve", "decode", "document", "line", "fold"]; + +/// One event's breakdown: per phase (indexed by `Phase as usize`) the microseconds and the largest probe value; the event's +/// total microseconds and the engine calls of the turn +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct TurnTrace { pub us: [u64; PHASES], pub bytes: [u64; PHASES], pub total_us: u64, pub solves: u32 } + +#[cfg(feature = "turn_trace")] +mod on { + use super::*; + use probbit_core::rt::Instant; + use std::cell::RefCell; + struct S { open: Option<(Instant, u64)>, cur: Phase, acc: TurnTrace, last: TurnTrace, probe: Option u64> } + thread_local! { static T: RefCell = RefCell::new(S { open: None, cur: Phase::Input, acc: TurnTrace::default(), last: TurnTrace::default(), probe: None }); } + fn us(t0: Instant) -> u64 { (t0.elapsed().as_secs_f64() * 1e6) as u64 } + /// The breakdown of the last traced event on this thread (zeros before the first) + pub fn last() -> TurnTrace { T.with(|t| t.borrow().last) } + /// Install (or remove) the probe: called with the phase at the end of each of its segments; its value is kept per phase as + /// the largest seen in the event. Its own time is not charged to the phase. + pub fn set_probe(f: Option u64>) { T.with(|t| t.borrow_mut().probe = f) } + pub(crate) fn begin() { T.with(|t| { let mut t = t.borrow_mut(); t.acc = TurnTrace::default(); t.cur = Phase::Input; t.open = Some((Instant::now(), 0)); }) } + /// End the current segment and start one of `ph` -> the phase that was current (a call into the engine restores it after) + pub(crate) fn enter(ph: Phase) -> Phase { + T.with(|t| { let mut g = t.borrow_mut(); let Some((t0, at)) = g.open else { return ph }; + let (prev, probe) = (g.cur, g.probe); let now = us(t0); g.acc.us[prev as usize] += now.saturating_sub(at); + if ph == Phase::Solve { g.acc.solves += 1; } + drop(g); + let b = probe.map(|f| f(prev)); + let mut g = t.borrow_mut(); if let Some(b) = b { let x = &mut g.acc.bytes[prev as usize]; *x = (*x).max(b); } + g.open = Some((t0, us(t0))); g.cur = ph; prev }) + } + pub(crate) fn end() { + let cur = T.with(|t| t.borrow().cur); enter(cur); + T.with(|t| { let mut g = t.borrow_mut(); if let Some((t0, _)) = g.open.take() { g.acc.total_us = us(t0); g.last = g.acc; } }) + } +} +#[cfg(feature = "turn_trace")] +pub use on::{last, set_probe}; +#[cfg(feature = "turn_trace")] +pub(crate) use on::{begin, end, enter}; +#[cfg(not(feature = "turn_trace"))] +#[inline(always)] +pub(crate) fn begin() {} +#[cfg(not(feature = "turn_trace"))] +#[inline(always)] +pub(crate) fn enter(ph: Phase) -> Phase { ph } +#[cfg(not(feature = "turn_trace"))] +#[inline(always)] +pub(crate) fn end() {} + +impl TurnTrace { + /// As JSON: `{"us": {phase: µs}, "bytes": {phase: value}, "total_us": n, "solves": n}` (phases in `Phase` order) + pub fn to_json(&self) -> crate::json::Json { + use crate::json::Json; + let row = |v: &[u64; PHASES]| Json::Obj(NAMES.iter().zip(v).map(|(k, x)| (k.to_string(), Json::Num(*x as f64))).collect()); + Json::Obj(vec![("us".into(), row(&self.us)), ("bytes".into(), row(&self.bytes)), ("total_us".into(), Json::Num(self.total_us as f64)), ("solves".into(), Json::Num(self.solves as f64))]) + } +} From 8885724f3f90fd98154d6e1f680323c6343cdb49 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 01:16:42 -0700 Subject: [PATCH 08/61] probbit-persona: the turn and resume fit a small stack: the turn's steps out of line with the state boxed through them (turn_boxed; Live::event runs it), the expectation's programs compiled the same way, the chain check opens the header without a replay (the header compared in pieces) and resume continues with its persona; every stance, line and digest as before --- probbit-persona/src/persona.rs | 136 ++++++++++++++++++++++++++++----- probbit-persona/src/strand.rs | 68 ++++++++++++----- 2 files changed, 167 insertions(+), 37 deletions(-) diff --git a/probbit-persona/src/persona.rs b/probbit-persona/src/persona.rs index 9e9f39c..4fe69d2 100644 --- a/probbit-persona/src/persona.rs +++ b/probbit-persona/src/persona.rs @@ -1461,6 +1461,9 @@ fn rest_apply(p: &Persona, st: &State) -> State { /// The rest line due before an event (RFC 0002 part 2), for the strand: its `rest` object (downscale, prune, the pressure, the /// learned weights after it, and with a surprise block each learned trait's gate as kept) and the state after it; None when no /// line is due. `ev` = the stamped event as the turn reads it. +/// `rest_line` as `Live::event` reads it: the `rest` object and the digest of the state after it (the turn applies the rest itself) +#[inline(never)] +pub(crate) fn rest_line_digest(p: &Persona, st: &State, ev: &Json) -> Option<(Json, String)> { rest_line(p, st, ev).map(|(r, s)| (r, s.digest)) } pub fn rest_line(p: &Persona, st: &State, ev: &Json) -> Option<(Json, State)> { let Json::Obj(raw) = ev else { return None }; if !rest_due(p, st, raw) { return None; } @@ -2510,6 +2513,7 @@ fn cond_true(p: &Persona, k: &str, c: &Json, vals: &[(String, Json)], hist: &[(S 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. +#[inline(never)] fn compile(p: &Persona, st: &State, raw: &[(String, Json)], o: &Opts) -> R<(Json, Meta)> { let (vals, mut hist, elapsed, ignored) = resolve_inputs(p, st, raw)?; if let Some(h) = &o.hist { hist = h.clone(); } // `prove`: history feature values given, not derived from the state @@ -2753,6 +2757,7 @@ fn back(p: &Persona, var: &str, val: &str) -> String { 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) +#[inline(never)] 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()); @@ -2833,6 +2838,7 @@ fn solve(p: &Persona, st: &State, prog: &Json, eng: Engine, calls: &mut usize) - 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 +#[inline(never)] 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), hist: o.hist.clone(), ..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")) }; @@ -2844,13 +2850,29 @@ fn habit_conflict(p: &Persona, st: &State, raw: &[(String, Json)], active: &[Str } /// 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)> { turn_src(p, st, raw, no_inertia, eng, timing, true) } +pub fn turn(p: &Persona, st: &State, raw: &Json, no_inertia: bool, eng: Engine, timing: bool) -> R<(Json, State)> { turn_src(p, st, raw, no_inertia, eng, timing, true).map(|(j, s)| (j, *s)) } +/// `turn` with the next state on the heap (`Live::event` runs it): the turn's steps are out of line and pass the state boxed, so +/// no frame of the turn holds a `State` by value and the deepest frame is one step's (docs/embedding.md "Stack budget") +pub fn turn_boxed(p: &Persona, st: &State, raw: &Json, no_inertia: bool, eng: Engine, timing: bool) -> R<(Json, Box)> { turn_src(p, st, raw, no_inertia, eng, timing, true) } /// `turn` without the provenance check (§2.10), for `fuzz` and `prove`: they search over stances, not over sources, so their events /// carry no `src` and stand for events of an accepted source -pub fn turn_any_source(p: &Persona, st: &State, raw: &Json, no_inertia: bool, eng: Engine, timing: bool) -> R<(Json, State)> { turn_src(p, st, raw, no_inertia, eng, timing, false) } -fn turn_src(p: &Persona, st: &State, raw: &Json, no_inertia: bool, eng: Engine, timing: bool, check: bool) -> R<(Json, State)> { - use crate::trace::{enter, Phase}; - enter(Phase::Prep); +pub fn turn_any_source(p: &Persona, st: &State, raw: &Json, no_inertia: bool, eng: Engine, timing: bool) -> R<(Json, State)> { turn_src(p, st, raw, no_inertia, eng, timing, false).map(|(j, s)| (j, *s)) } +/// A turn's inputs and state after the steps before the compiler (`turn_prep`): the stepped state, the compiler's inputs, the +/// inputs as given, the event's `src`, its payments and what they settled +struct Prepped { st: Box, raw: Vec<(String, Json)>, given: Vec<(String, Json)>, src: Option, pay: Option, settled: Option } +/// The engine's answer read back (`turn_solve`): the stance under construction and the next state +struct Solved { out: Box, ns: Box } +#[inline(never)] +fn turn_src(p: &Persona, st: &State, raw: &Json, no_inertia: bool, eng: Engine, timing: bool, check: bool) -> R<(Json, Box)> { + let pr = turn_prep(p, st, raw, check)?; + let sv = turn_solve(p, &pr, no_inertia, eng, timing)?; + Ok(turn_doc(p, pr, sv)) +} +/// The steps before the compiler: the inputs read, `src` and the payments taken out, a rest due applied, then the sense / needs / +/// drive / pace steps, the credit step and the surprise gate, each on the heap +#[inline(never)] +fn turn_prep(p: &Persona, st: &State, raw: &Json, check: bool) -> R { + crate::trace::enter(crate::trace::Phase::Prep); 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")); } } // reward provenance (§2.10), first: with `reward_from` (or a senses block, §2.11), `src` is read here (not compiled, so not @@ -2862,12 +2884,34 @@ fn turn_src(p: &Persona, st: &State, raw: &Json, no_inertia: bool, eng: Engine, // a senses block steps the normals and a drives block the drives before the turn, which runs on the stepped state with the // lowering's inputs; then the credit step settles what this turn's feedback pays // RFC 0002 part 2: a rest line due before this event scales the learned weights first (the strand logs it as its own line) - let rested; let st = if rest_due(p, st, &raw) { rested = rest_apply(p, st); &rested } else { st }; - let given = raw; let (st2, raw) = prep(p, st, &given, src.as_ref(), check)?; - let (st2, settled) = match &pay { Some(k) => { let (s, d) = credit_step(p, &st2, &raw, k, src.as_ref(), check)?; (s, Some(d)) } None => (st2, None) }; + let rested = if rest_due(p, st, &raw) { Some(rest_boxed(p, st)) } else { None }; + let st = rested.as_deref().unwrap_or(st); + let given = raw; let (st2, raw) = prep_boxed(p, st, &given, src.as_ref(), check)?; + let (st2, settled) = match &pay { Some(k) => { let (s, d) = credit_boxed(p, &st2, &raw, k, src.as_ref(), check)?; (s, Some(d)) } None => (st2, None) }; // RFC 0002 Design B: the surprise gate steps v, r̂, g and the counts of each paid learned trait before the learner reads g - let st2 = gate_step(p, st2, &raw)?; - let st = &st2; + let st2 = gate_boxed(p, st2, &raw)?; + Ok(Prepped { st: st2, raw, given, src, pay, settled }) +} +#[inline(never)] +fn rest_boxed(p: &Persona, st: &State) -> Box { Box::new(rest_apply(p, st)) } +#[inline(never)] +fn prep_boxed(p: &Persona, st: &State, raw: &[(String, Json)], src: Option<&Json>, check: bool) -> R<(Box, Vec<(String, Json)>)> { let (s, x) = prep(p, st, raw, src, check)?; Ok((Box::new(s), x)) } +#[inline(never)] +fn credit_boxed(p: &Persona, st: &State, host: &[(String, Json)], k: &Pay, src: Option<&Json>, check: bool) -> R<(Box, Settled)> { let (s, d) = credit_step(p, st, host, k, src, check)?; Ok((Box::new(s), d)) } +#[inline(never)] +fn gate_boxed(p: &Persona, st: Box, raw: &[(String, Json)]) -> R> { Ok(Box::new(gate_step(p, *st, raw)?)) } +#[inline(never)] +fn decode_boxed(p: &Persona, st: &State, program: &str, meta: &Meta, doc: &Json, conflict: Option<&[String]>, yielded: Option<&[String]>) -> (Box, Box) { + let (o, s) = decode(p, st, program, meta, doc, conflict, yielded); (Box::new(o), Box::new(s)) +} +#[inline(never)] +fn settle_boxed(p: &Persona, j: &mut Json, ns: Box, k: &Pay, s: Settled) -> Box { Box::new(settle(p, j, *ns, k, s)) } +/// The compiler, the engine and the decoder: the program for the stepped state, its solve (a yield on a habit conflict, a second, +/// clamped solve for the persona's vouch rule) and the answer read back +#[inline(never)] +fn turn_solve(p: &Persona, pr: &Prepped, no_inertia: bool, eng: Engine, timing: bool) -> R { + use crate::trace::{enter, Phase}; + let (st, raw): (&State, &[(String, Json)]) = (&pr.st, &pr.raw); enter(Phase::Compile); let t0 = Instant::now(); let mut calls = 0; let o = Opts { no_inertia, ..Default::default() }; @@ -2885,8 +2929,10 @@ fn turn_src(p: &Persona, st: &State, raw: &Json, no_inertia: bool, eng: Engine, 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); + // the program is read no more (its digest is kept): not held through the decode and a hold's solve + drop(prog); enter(Phase::Decode); - let (mut out, mut ns) = decode(p, st, &ptext, &meta, &doc, conflict.as_deref(), yielded.as_deref()); let lift = out.lift.clone(); + let (mut out, mut ns) = decode_boxed(p, st, &ptext, &meta, &doc, conflict.as_deref(), yielded.as_deref()); let lift = out.lift.clone(); // 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(); @@ -2895,7 +2941,7 @@ fn turn_src(p: &Persona, st: &State, raw: &Json, no_inertia: bool, eng: Engine, enter(Phase::Compile); let o2 = Opts { no_inertia, clamps: holds.clone(), ..Default::default() }; let (prog2, meta2) = compile(p, st, &raw, &o2)?; enter(Phase::Decode); 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); + let (mut out2, ns2) = decode_boxed(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(); @@ -2904,20 +2950,27 @@ fn turn_src(p: &Persona, st: &State, raw: &Json, no_inertia: bool, eng: Engine, 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; out.lift = lift; - enter(Phase::Document); 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(Solved { out, ns }) +} +/// The stance document from the decoded stance: its senses, drives and body blocks, `src` back in its inputs, the credit settled, +/// the injected block and the glyph +#[inline(never)] +fn turn_doc(p: &Persona, pr: Prepped, sv: Solved) -> (Json, Box) { + crate::trace::enter(crate::trace::Phase::Document); + let Prepped { st, given, src, pay, settled, .. } = pr; let st: &State = &st; let Solved { out, mut ns } = sv; let mut j = out.to_json(p, st); if let (Some(sn), Some(ss)) = (&p.senses, &st.senses) { stance_senses(&mut j, sn, ss, &given); } if let (Some(d), Some(ds)) = (&p.drives, &st.drives) { stance_drives(&mut j, d, ds, &out.lift, &given); } stance_body(&mut j, p, st); if let (Some(s), Json::Obj(v)) = (src, &mut j) { if let Some((_, Json::Obj(ins))) = v.iter_mut().find(|(k, _)| k == "inputs") { ins.push(("src".into(), s)); } } - if let (Some(k), Some(d)) = (&pay, settled) { ns = settle(p, &mut j, ns, k, d); } + if let (Some(k), Some(d)) = (&pay, settled) { ns = settle_boxed(p, &mut j, ns, k, d); } if p.has_block() { let b = block(p, &j); if let Json::Obj(v) = &mut j { v.push(("block".into(), Json::Str(b))); } } // §2.18: the glyph last, from the finished document (`probbit live` sets it again once `deviation` is in) set_glyph(p, &mut j); - Ok((j, ns)) + (j, ns) } /// RFC 0008 E: the block a host injects verbatim, rendered from a stance document: every declared trait, then every mood, once @@ -3041,14 +3094,16 @@ pub fn replay(p: &Persona, seed: Option, turns: &[Json], no_inertia: bool, } /// `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 { Ok(text(&program_json(p, st, raw)?)) } -/// `program` as a document (RFC 0009: the expectation's members and the trajectory compile through it) +/// `program` as a document (RFC 0009: the expectation's members and the trajectory compile through it); the steps before the +/// compiler pass the state boxed, as in the turn +#[inline(never)] pub fn program_json(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")) }; let (src, raw) = if p.senses.is_some() || p.credit().is_some() || p.has_needs() { take_src(p, raw, true)? } else { (None, raw) }; let (pay, raw) = take_pay(p, raw)?; - let (st, raw) = prep(p, st, &raw, src.as_ref(), true)?; - let st = match &pay { Some(k) => credit_step(p, &st, &raw, k, src.as_ref(), true)?.0, None => st }; - let st = gate_step(p, st, &raw)?; + let (st, raw) = prep_boxed(p, st, &raw, src.as_ref(), true)?; + let st = match &pay { Some(k) => credit_boxed(p, &st, &raw, k, src.as_ref(), true)?.0, None => st }; + let st = gate_boxed(p, st, &raw)?; Ok(compile(p, &st, &raw, &Opts::default())?.0) } @@ -3895,6 +3950,7 @@ pub fn expect_weights(ex: &Expect, mem: &ExpectMem) -> Vec<(String, f64)> { /// Per non-twin variable its exact marginal, levels sorted type Marg = Vec<(String, Vec<(String, f64)>)>; /// The non-twin exact marginals of a program -> None unless the exact tier solved it +#[inline(never)] fn exact_marg(p: &Persona, st: &State, prog: &Json, eng: Engine) -> Option { let doc = eng(prog, &Flags { op: "decide".into(), ..flags(p, st) }); if doc.get("verdict").and_then(Json::as_str) != Some("exact") { return None; } @@ -3917,6 +3973,7 @@ fn kl_marg(a: &Marg, b: &Marg) -> Option<(f64, Vec)> { /// programs (each combo's latest inputs with this event's hours; the event's own combo with its own inputs, so A_t is a member /// whenever its combo is). Mixture marginals are linear in the members' exact marginals, so every number is exact from the /// M + 1 solves. None without the block, or when a program is not solved exactly or a divergence is not finite. +#[inline(never)] pub fn deviation(p: &Persona, st: &State, raw: &Json, mem: &ExpectMem, eng: Engine) -> Option { let ex = p.expect.as_ref()?; let kv = raw.as_obj()?; let e = get(kv, "elapsed_hours").cloned().unwrap_or(Json::Num(0.0)); let key = combo_key(p, kv); @@ -3987,6 +4044,7 @@ pub fn trajectory_program(prog: &Json, k: usize, beta0: f64, limit: u64) -> Opti Some(Json::Obj(vec![("k".into(), Json::Num(k as f64)), ("beta0".into(), Json::Num(beta0)), ("steps".into(), Json::Arr(steps))])) } /// The event's trajectory with the persona's `engine.trajectory` (the program `persona compile` gives for the event) +#[inline(never)] pub fn trajectory(p: &Persona, st: &State, raw: &Json) -> Option { let t = p.traj.as_ref()?; trajectory_program(&program_json(p, st, raw).ok()?, t.k, t.beta0, p.eng.exact_limit) } @@ -4122,6 +4180,46 @@ mod tests { let r1 = strand::resume(&text, &p, a.st.clone(), Clock::Fixed).unwrap(); let r2 = strand::resume_boxed(&text, &p, Box::new(c.st.clone()), Clock::Fixed).unwrap(); assert_eq!((r1.n, r1.head(), &r1.st.digest), (r2.n, r2.head(), &r2.st.digest)); } + /// The boxed turn is the turn: the same stance documents and states over a few events (a habit firing, feedback, none), and + /// `Live::event` (which runs it) chains the same lines as before + #[test] + fn the_boxed_turn_is_the_turn() { + let p = persona(LEARN).unwrap(); let mut st = init(&p, Some(5), true, &run); + for (pr, cr, loss) in [(false, false, false), (true, false, false), (false, true, true), (true, false, true), (false, false, false)] { + let ins = inputs(pr, cr, loss); + let (d1, s1) = turn(&p, &st, &ins, false, &run, false).unwrap(); let (d2, s2) = turn_boxed(&p, &st, &ins, false, &run, false).unwrap(); + assert_eq!(canon(&d1), canon(&d2)); assert_eq!(canon(&s1.to_json(&p)), canon(&s2.to_json(&p))); st = s1; } + assert_eq!(err(turn_boxed(&p, &st, &json::parse("[1]").unwrap(), false, &run, false)), "inputs: must be a JSON object"); + } + /// The chain check opens the header without a replay and compares it in pieces: a header that reads the same but is not as + /// written (one space more) is refused as before, and a strand with checkpoints still checks + #[test] + fn the_chain_check_compares_the_header_in_pieces() { + use crate::strand::{self, Clock, Live}; + let doc = json::parse(&DOC.replace("LEARN", LEARN)).unwrap(); let p = build(&doc).unwrap(); + let (mut lv, header) = Live::start(p.clone(), &doc, init(&p, Some(2), true, &run), Clock::Fixed, "probbit 0.14.0"); + let mut text = format!("{header}\n"); + for k in 0..4 { let (s, l) = lv.event(&inputs(k % 2 == 0, false, k == 3), &run).unwrap(); text += &l; text.push('\n'); if k == 1 { text += &lv.checkpoint(&s); text.push('\n'); } } + assert_eq!(strand::check_chain(&text).unwrap(), strand::Status::Active); + let r = strand::resume(&text, &p, lv.st.clone(), Clock::Fixed).unwrap(); assert_eq!((r.n, r.head()), (4, lv.head())); + let spaced = text.replacen(r#""seed":"#, r#""seed": "#, 1); + let e = strand::check_chain(&spaced).unwrap_err(); assert_eq!(e.msg, "line 1: the header is not as written"); + let e = strand::resume(&spaced, &p, lv.st.clone(), Clock::Fixed).err().unwrap(); assert_eq!(e.msg, "line 1: the header is not as written"); + } + /// With `turn_trace`, an event's breakdown: every phase entered once at least (a probe returning the phase's number + 1 is + /// kept per phase), an engine call counted, and the phases' microseconds within the event's + #[cfg(feature = "turn_trace")] + #[test] + fn turn_trace_reports_every_phase_of_an_event() { + use crate::{strand::{Clock, Live}, trace}; + let doc = json::parse(&DOC.replace("LEARN", LEARN)).unwrap(); let p = build(&doc).unwrap(); + let (mut lv, _) = Live::start(p.clone(), &doc, init(&p, Some(2), true, &run), Clock::Fixed, "probbit 0.14.0"); + fn probe(ph: trace::Phase) -> u64 { ph as u64 + 1 } + trace::set_probe(Some(probe)); lv.event(&inputs(true, false, false), &run).unwrap(); trace::set_probe(None); + let t = trace::last(); assert_eq!(t.bytes, [1, 2, 3, 4, 5, 6, 7, 8, 9]); assert!(t.solves >= 1); + assert!(t.us.iter().sum::() <= t.total_us + trace::PHASES as u64, "{t:?}"); + assert_eq!(g(&t.to_json(), &["us"]).as_obj().map(|o| o.len()), Some(trace::PHASES)); + } /// The header written in pieces is `Live::start`'s header byte for byte, and its returned digest is the chain's first `prev`, /// at every piece size (1 byte, a size that cuts inside UTF-8 characters, the default, one piece for the whole line) #[test] diff --git a/probbit-persona/src/strand.rs b/probbit-persona/src/strand.rs index b0df804..6887fa0 100644 --- a/probbit-persona/src/strand.rs +++ b/probbit-persona/src/strand.rs @@ -148,15 +148,15 @@ impl Live { // RFC 0009: the deviation and the trajectory are read from the state before the event (effects {}: the turn is as without them) let dev = self.expect.as_ref().and_then(|m| persona::deviation(&self.p, &self.st, &inputs, m, eng)); let traj = persona::trajectory(&self.p, &self.st, &inputs); - let (mut stance, ns) = persona::turn(&self.p, &self.st, &inputs, false, eng, false)?; + let (mut stance, ns) = persona::turn_boxed(&self.p, &self.st, &inputs, false, eng, false)?; let sd = persona::stance_sha(&stance); if self.expect.is_some() { if let Json::Obj(v) = &mut stance { v.push(("deviation".into(), dev.as_ref().map_or(Json::Null, |d| d.to_json(self.dev_full)))); } } // §2.18: the glyph reads the finished document, `deviation` included (the surprise modifier) persona::set_glyph(&self.p, &mut stance); // RFC 0002 part 2: a rest line due before this event is its own line, chained before the event line (the turn applied it) enter(Phase::Line); - let (rest, prev) = match persona::rest_line(&self.p, &self.st, &inputs) { - Some((r, rs)) => { let l = rest_line(&self.prev, r, &rs.digest); let d = persona::digest_of(&l); (Some(l), d) } + let (rest, prev) = match persona::rest_line_digest(&self.p, &self.st, &inputs) { + Some((r, rd)) => { let l = rest_line(&self.prev, r, &rd); let d = persona::digest_of(&l); (Some(l), d) } None => (None, self.prev.clone()) }; let mut lj = vec![("n".into(), Json::Num((self.n + 1) as f64)), ("inputs".into(), inputs.clone()), ("stance".into(), Json::Str(sd)), ("state".into(), Json::Str(ns.digest.clone())), ("prev".into(), Json::Str(prev))]; @@ -167,7 +167,7 @@ impl Live { if let Some(m) = self.expect.as_mut() { persona::expect_step(&self.p, m, &inputs, dev.as_ref().and_then(persona::Deviation::logged_pred)); } self.traj = traj; enter(Phase::Line); - self.n += 1; self.last = now; self.prev = persona::digest_of(&line); self.st = ns; + self.n += 1; self.last = now; self.prev = persona::digest_of(&line); self.st = *ns; let line = match &rest { Some(r) => { self.rests += 1; format!("{r}\n{line}") } None => line }; crate::trace::end(); Ok((stance, line)) @@ -583,10 +583,38 @@ pub fn engine() -> String { format!("probbit {}", env!("CARGO_PKG_VERSION")) } /// Validate the stored chain and lifecycle before extending it. This is deliberately not /// inference replay or authentication: verify still recomputes every event. In particular, a /// checkpoint cannot reactivate a retired individual or hide an invalid earlier control. -pub fn check_chain(text: &str) -> Result { +pub fn check_chain(text: &str) -> Result { check_chain_head(text).map(|(s, _, _)| s) } +/// The header checks of `Replay::open` without a replay (the same checks, in its order, with its messages): the header read, its +/// persona built on the heap, its digest and initial state checked, and the header written again piece by piece against the line +/// (no `Live`, no whole header) -> the persona and the header +#[inline(never)] +fn open_head(head: &str) -> Result<(Box, Json), (usize, String)> { + let h = json::parse(head).map_err(|e| (1, format!("the header is not JSON: {}", e.msg)))?; + if h.get("probbit_strand").and_then(Json::as_f64) != Some(FORMAT) { return Err((1, "not a probbit strand (format 1)".into())); } + if let Some((k, _)) = h.as_obj().and_then(|kv| kv.iter().find(|(k, _)| !HEADER.contains(&k.as_str()))) { return Err((1, unsupported(k))); } + let doc = h.get("document").ok_or((1, "the header has no persona document".to_string()))?; + let p = persona::build_boxed(doc).map_err(|e| (1, read_err(e, "the persona document: ")))?; + if h.get("persona").and_then(|x| x.get("digest")).and_then(Json::as_str) != Some(p.digest.as_str()) { return Err((1, "the persona digest does not match the document".into())); } + let st0 = read_boxed(&p, h.get("state").unwrap_or(&Json::Null)).map_err(|e| (1, read_err(e, "the initial state: ")))?; + let (hb, mut at, mut same) = (head.as_bytes(), 0usize, true); + write_header(&p, doc, &st0, h.get("engine").and_then(Json::as_str).unwrap_or(""), HEADER_CHUNK, &mut |c: &[u8]| { same = same && hb.get(at..at + c.len()) == Some(c); at += c.len(); }); + if !same || at != hb.len() { return Err((1, "the header is not as written".into())); } + Ok((p, h)) +} +/// A checkpoint line opened as a replay starts there (`Replay::open_at`) -> its event count and its persona's digest; out of line, +/// so the chain check's frame does not hold the replay +#[inline(never)] +fn checkpoint_at(head: &str, before: &str, cp: &str, at: usize) -> Result<(u64, String), (usize, String)> { + let r = Replay::open_at(head, before, cp, at)?; Ok((r.live.n, r.live.p.digest.clone())) +} +#[inline(never)] +fn read_boxed(p: &Persona, j: &Json) -> Result, InErr> { State::read(p, j).map(Box::new) } +/// `check_chain` -> the status, the header's persona and the header (`resume_boxed` continues with them) +#[inline(never)] +fn check_chain_head(text: &str) -> Result<(Status, Box, Json), InErr> { let ls = lines(text); let err = |line: usize, why: String| perr("strand", format!("line {line}: {why}")); - let r = Replay::open(ls.first().copied().unwrap_or("")).map_err(|(i, why)| err(i, why))?; + let (hp, h) = open_head(ls.first().copied().unwrap_or("")).map_err(|(i, why)| err(i, why))?; let mut status = Status::Active; let mut n = 0u64; for i in 1..ls.len() { @@ -621,14 +649,14 @@ pub fn check_chain(text: &str) -> Result { Kind::Checkpoint => { if status != Status::Active { return Err(err(i + 1, format!("checkpoint while {}", status.name()))); } // Full state validation plus links to the immediately preceding event; no inference. - let cp = Replay::open_at(ls[0], ls[i - 1], ls[i], i + 1).map_err(|(at, why)| err(at, why))?; - if cp.live.n != n { return Err(err(i + 1, "checkpoint event count differs".into())); } + let (cn, cd) = checkpoint_at(ls[0], ls[i - 1], ls[i], i + 1).map_err(|(at, why)| err(at, why))?; + if cn != n { return Err(err(i + 1, "checkpoint event count differs".into())); } // Keep the header persona/state check independent of the caller's state. - debug_assert_eq!(cp.live.p.digest, r.live.p.digest); + debug_assert_eq!(cd, hp.digest); } } } - Ok(status) + Ok((status, hp, h)) } /// Continue a strand: `text` is the strand so far and `st` the individual after its last line -> the individual, ready for the @@ -640,11 +668,9 @@ pub fn resume(text: &str, p: &Persona, st: State, clock: Clock) -> Result, clock: Clock) -> Result, InErr> { let bad = |m: String| perr("strand", m); if !text.ends_with('\n') { return Err(bad("its last line is incomplete (no newline at the end)".into())); } - check_chain(text)?; + // the chain check opens the header (its persona built once, on the heap): resume continues with that persona + let (_, q, h) = check_chain_head(text)?; let lines = lines(text); - let h = json::parse(lines.first().copied().unwrap_or("")).map_err(|e| bad(format!("the header is not JSON: {}", e.msg)))?; - if h.get("probbit_strand").and_then(Json::as_f64) != Some(FORMAT) { return Err(bad("not a probbit strand (format 1)".into())); } - let q = persona::build_boxed(h.get("document").unwrap_or(&Json::Null)).map_err(|e| bad(format!("the header's persona: {}: {}", e.path, e.msg)))?; if q.digest != p.digest { return Err(bad(format!("it is another persona's ({} != {})", &q.digest[..19], &p.digest[..19]))); } let last = lines[lines.len() - 1]; // the control lines after the last event or checkpoint line (the anchor; the header without either) set the status: an @@ -658,10 +684,7 @@ pub fn resume_boxed(text: &str, p: &Persona, st: Box, clock: Clock) -> Re _ => (j.get("n").and_then(Json::as_f64).unwrap_or(0.0) as u64, j.get("state").and_then(Json::as_str).unwrap_or("").to_string()) } }; if st.digest != want { return Err(perr("state", format!("not the strand's last state ({} != {})", &st.digest[..st.digest.len().min(19)], &want[..want.len().min(19)]))); } // RFC 0009: the expectation's memory, folded from every event line's inputs and logged deviation - let expect = q.expect.as_ref().map(|_| { let mut m = persona::ExpectMem::new(); - for l in lines.iter().skip(1) { if let Ok(j) = json::parse(l) { if matches!(kind(&j), Kind::Event) { let d = j.get("deviation"); - let lp = d.filter(|d| d.get("cold").is_none()).and_then(|d| d.get("pred")).and_then(Json::as_f64); - persona::expect_step(&q, &mut m, j.get("inputs").unwrap_or(&Json::Null), lp); } } } m }); + let expect = q.expect.as_ref().map(|_| expect_fold(&q, &lines)); let dev_full = engine_version(h.get("engine").and_then(Json::as_str).unwrap_or("")).is_some_and(|v| v < DEV_R6_SINCE); let mut lv = Live::boxed(q, st, clock, persona::digest_of(last), n, expect, dev_full); for c in tail.iter().rev() { let what = c.get("control").and_then(Json::as_str).unwrap_or(""); @@ -669,6 +692,15 @@ pub fn resume_boxed(text: &str, p: &Persona, st: Box, clock: Clock) -> Re Ok(lv) } +/// RFC 0009: the expectation's memory folded from the event lines after the header, one line read at a time +#[inline(never)] +fn expect_fold(q: &Persona, lines: &[&str]) -> persona::ExpectMem { + let mut m = persona::ExpectMem::new(); + for l in lines.iter().skip(1) { if let Ok(j) = json::parse(l) { if matches!(kind(&j), Kind::Event) { let d = j.get("deviation"); + let lp = d.filter(|d| d.get("cold").is_none()).and_then(|d| d.get("pred")).and_then(Json::as_f64); + persona::expect_step(q, &mut m, j.get("inputs").unwrap_or(&Json::Null), lp); } } } + m +} /// The audit of a recorded life (`probbit persona bound PERSONA --audit STRAND`): the strand replayed line by line; after every /// event and control line, per learned trait the resting TV between the individual and the same individual with its learned /// weights at zero, as a share of its epsilon (the room used). An exceedance is impossible by the theorem; the audit shows it From 7b343143fbed33c13fd19f574f05644bd4ff71e9 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 01:41:46 -0700 Subject: [PATCH 09/61] probbit-persona: the canonical writer sorts an object's keys in place (a stable binary insertion sort up to 512 entries: the library's stable sort keeps a 4 KB scratch buffer in its frame); the same stable order, the same bytes --- probbit-persona/src/persona.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/probbit-persona/src/persona.rs b/probbit-persona/src/persona.rs index 4fe69d2..685b939 100644 --- a/probbit-persona/src/persona.rs +++ b/probbit-persona/src/persona.rs @@ -67,10 +67,17 @@ pub fn write_to(j: &Json, sort: bool, s: &mut impl Sink) { 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_to(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)); } + Json::Obj(v) => { let mut o: Vec<&(String, Json)> = v.iter().collect(); if sort { sort_keys(&mut o); } s.push('{'); for (k, (key, x)) in o.into_iter().enumerate() { if k > 0 { s.push(','); } esc(key, s); s.push(':'); write_to(x, sort, s); } s.push('}'); } } } +/// An object's entries sorted by key (by code point), stably: a binary insertion sort in place up to 512 entries (the library's +/// stable sort keeps a 4 KB scratch buffer in its frame, the largest frame of a canonical write on a small stack), the library's +/// above. Both are stable, so both give the one stable order: the bytes are the same. +fn sort_keys(o: &mut [&(String, Json)]) { + if o.len() > 512 { o.sort_by(|a, b| a.0.cmp(&b.0)); return; } + for i in 1..o.len() { let at = o[..i].partition_point(|x| x.0 <= o[i].0); o[at..=i].rotate_right(1); } +} /// 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_to(j, true, &mut s); s } /// A program's text: insertion order (the compiler's fixed order), compact, canonical numbers. `engine.program` is its sha256. From d40ffc474e4de221b266b4a0fcc3934c138d9a48 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 01:46:19 -0700 Subject: [PATCH 10/61] docs: the embedding guide's stack budget gains the turn and resume rows, the sizing rule (size the engine stack from the turn) and the turn's phases (turn_trace); CHANGELOG --- CHANGELOG.md | 9 ++++++++ docs/embedding.md | 52 +++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11c4b58..d9f94b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,15 @@ All notable changes to this project are documented here. The format follows calls are unchanged wrappers; every header, line and digest is byte for byte as before. `persona::sha256` hashes without copying its input (`persona::Sha256`, fed in pieces). The YAML reader refuses collections nested deeper than 256 (a `limit` error, as the JSON reader does) instead of recursing without a bound. +- **The turn and `resume` fit a small stack too** (docs/embedding.md "Stack budget"): the turn's steps run out of line and pass + the state boxed (`persona::turn_boxed`, which `Live::event` runs; `turn` is its wrapper), the expectation's programs compile + the same way, and `resume` opens the strand's header once (the chain check builds the persona on the heap and compares the + header in pieces, without a replay). On the host proxy one event's stack high-water fell from 31,824 to 13,808 bytes and + `resume`'s from 30,560 to 15,792; every stance document, strand line and digest is byte for byte as before. +- **`turn_trace`** (feature of probbit-persona and probbit-cli, off by default): `persona::trace` records the last event's + microseconds and a host probe's value per phase (input, expect, prep, compile, solve, decode, document, line, fold), so a + firmware measures the same breakdown on its hardware; `probbit live` in such a build prints it on stderr when + `PROBBIT_TURN_TRACE` is set. ### Fixed diff --git a/docs/embedding.md b/docs/embedding.md index b531db4..49949e2 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -68,6 +68,7 @@ let st = persona::init_boxed(&p, None, true, &eng); // Box< let live = Live::start_to(p, &doc, st, Clock::Real(probbit_core::rt::Instant::now()), &strand::engine(), 1024, &mut out); // or: let (live, header) = Live::start_boxed(p, &doc, st, clock, &engine); // Box and the header whole // a stored strand: let live = strand::resume_boxed(&text, &p, st, clock)?; // Box +// each event: live.event(&ev, &eng)? -> (stance, line); the turn inside passes its state boxed (persona::turn_boxed) ``` `build`, `init`, `Live::start` and `resume` stay as they were (thin wrappers over the boxed calls) for hosts with a large stack. @@ -88,14 +89,53 @@ persona (traits, moods, senses, drives, needs, expectation, display; its header | `init` → `init_boxed` | 1,184 → 624 (+ `init` 1,184) | 9,240 → 9,240 | 69,365 → 69,365 | | `Live::start` → `Live::start_to` / `start_boxed` | 1,888 → 160 / 96 (`write_header` 448) | 9,872 → 4,560 | 87,482 → 19,181 | | **setup: parse + build + init + start** | | **39,056 → 12,112** | 332,669 → 334,165 | -| `resume` → `resume_boxed` | 4,880 → 960 | 55,328 → 29,264 | 397,428 → 398,924 | -| one event (`Live::event`) | 2,688 → 2,672 (`turn_src` 6,160) | 31,904 → 31,824 | 112,990 → 112,990 | +| `resume` → `resume_boxed` | 4,880 → 960 → 896 (`check_chain` 6,320 → 96 + `check_chain_head` 384) | 55,328 → 29,264 (30,560 measured again with the phase rows added) → **15,792** | 397,428 → 398,924 → 356,129 | +| one event (`Live::event`) | 2,688 → 2,672 → 960 (`turn_src` 6,160 → 528; its steps `turn_prep` 448, `turn_solve` 1,312, `turn_doc` 704) | 31,904 → 31,824 → **13,808** | 112,990 → 112,990 → 88,149 | | the solve alone (in the event) | | 6,856 | 29,009 | -| a checkpoint line | 160 | 5,408 | 42,594 | +| a checkpoint line | 160 | 5,408 → **3,264** (the key sort in place) | 42,594 | -The setup now needs less stack than one event: the engine thread's stack is sized by the turn (about 32 KB on the proxy; -`turn_src` holds several `State` values by value), the next thing to cut. The heap peak of setup is the reader's (the -document tree and the lowered documents); `start` no longer holds a copy of the document and the whole line. +Three columns where a row has them: before the boxed setup, after it, and after the boxed turn and resume. The turn's steps +(`turn_prep`, `turn_solve`, `turn_doc`, the state steps, the decoder) are out of line and pass the state boxed, so the deepest +frame is one step's; the expectation's programs (`program_json`) compile the same way. `resume` opens the header once: the chain +check builds its persona on the heap, writes the header again in pieces against the stored line (no `Live`, no whole line) and +`resume` continues with that persona instead of building it a second time; the expectation's fold reads one line at a time out +of line. The heap peak of setup and of `resume` is the reader's (the document tree and the lowered documents); the event's +(88,149 B) is the live set of a turn: the expectation's exact solves, then the program as a document, the engine's answer and +the stance under construction (per phase, the peak above the event's start: the expectation's solves 66,879, prep 20,221, +compile 73,957, the solve 88,149, decode 76,529, document 60,295; the program tree is held from the compiler to the engine and +the engine's answer to the decoder). + +**Size the engine stack from the turn** (and from `resume` for a host that boots on a stored strand), not from setup: setup, one +event and `resume` now need about the same (12,112, 13,808 and 15,792 B on the proxy), so the largest of the three plus the +host's own frames is the thread's stack. + +### The turn's phases (`turn_trace`) + +With the feature `turn_trace` (off by default; without it the hooks are empty inline functions) `Live::event` records per +phase the microseconds spent and the largest value of a probe the host installs, so a firmware reads the same breakdown on +its own clock and counters: + +```rust +use probbit_persona::trace::{self, Phase}; +fn probe(_: Phase) -> u64 { /* e.g. the allocator's peak since the last call, reset; or the task's stack high-water */ 0 } +trace::set_probe(Some(probe)); // optional: called at the end of each phase's segment +let (stance, line) = live.event(&ev, &eng)?; +let t = trace::last(); // t.us[Phase::Solve as usize], t.bytes[..], t.total_us, t.solves +``` + +The phases: `input` (the event stamped), `expect` (the deviation and the trajectory: exact solves on the state before the +event), `prep` (provenance, payments, rest, senses / needs / drives / pace, credit, the surprise gate), `compile`, `solve` +(the engine's calls), `decode`, `document` (the stance's blocks, credit, glyph, digest), `line` (the strand line and a rest +line due before it) and `fold` (the expectation's memory). The parse of the event line and the store of the strand line are +the host's, before and after the call. `probbit live` built with `--features turn_trace` prints one `trace` line per event +on stderr when `PROBBIT_TURN_TRACE` is set (with the parse and the append it timed); stdout and the strand are unchanged. + +On the proxy (the body persona's last recorded event, x86_64, median of 21 runs) the turn takes 1,249 µs: expect 727, compile +130, solve 117, decode 102, document 97, prep 49, line 13, fold 4 (the host's parse of the event line and its append to a buffer +are about 1 µs each). The solve is 9.4% of it; the expectation's exact solves are 58%. Stack high-water and heap peak per phase +on the proxy: expect 15,520 / 66,879, prep 14,880 / 20,221, decode 12,464 / 76,529, solve 11,544 / 88,149, compile 8,816 / +73,957, document 6,192 / 60,295, fold 4,592 / 35,154, line 4,088 / 35,745, input 3,424 / 1,005 (with the feature compiled in, +which adds about 1.7 KB to the event's stack: 15,520 against 13,808 without it). Silicon numbers (the same calls on the target's engine thread) are measured by the firmware and will follow; the x86_64 proxy and an Xtensa build differ in frame layout, so read the proxy as the shape and the ratio, not the target's bytes. From d3de9879b379a98d113f10e54101ce3243e4ea98 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 02:23:46 -0700 Subject: [PATCH 11/61] probbit-persona: resume after a checkpoint line with an expectation block: the chain check validates the checkpoint (its state read, its count, its link to the event before) without opening it as a replay start, which such a persona cannot do; open_at keeps its refusal; every line and digest as before --- probbit-cli/tests/live.rs | 29 ++++++++++++++++++ probbit-persona/src/strand.rs | 57 ++++++++++++++++++----------------- 2 files changed, 59 insertions(+), 27 deletions(-) diff --git a/probbit-cli/tests/live.rs b/probbit-cli/tests/live.rs index 49bd10b..a3b2291 100644 --- a/probbit-cli/tests/live.rs +++ b/probbit-cli/tests/live.rs @@ -653,3 +653,32 @@ fn verify_names_the_writer_and_the_verifier() { assert_eq!((s(&v, &["ok"]), s(&v, &["events"])), (&Json::Bool(true), &Json::Num(1.0)), "{out}"); assert_eq!((s(&v, &["engine"]), s(&v, &["verified_by"])), (&Json::Str("probbit 0.11.0".into()), &Json::Str(THIS.into())), "{out}"); } + +/// A stored strand with checkpoint lines resumes, with and without an expectation block (RFC 0009): the chain check validates +/// each checkpoint line (its state read, its count, its link to the event before) without opening it as a replay start, which a +/// persona with the block cannot do. 42 events with a checkpoint after every 10th, then one more event in a second run: exit 0, +/// `verify` passes, and the strand, the state and the stances are those of the 43 events in one run, byte for byte. +#[test] +fn a_strand_with_checkpoints_resumes_with_and_without_an_expectation_block() { + let evs: Vec = (0..43).map(|t| match t % 4 { 0 => r#"{"elapsed_hours":1,"praise":true}"#, 1 => r#"{"elapsed_hours":0.5}"#, + 2 => r#"{"elapsed_hours":2,"error":true}"#, _ => r#"{"elapsed_hours":1,"loss":true}"# }.to_string()).collect(); + for (persona, tag) in [(TUTOR, "plain"), ("probbit-cli/tests/fixtures/monitor/tutor-expect.yaml", "expect")] { + let all = events_file(&format!("cp-{tag}-all.jsonl"), &evs); + let (head, last) = (events_file(&format!("cp-{tag}-a.jsonl"), &evs[..42]), events_file(&format!("cp-{tag}-b.jsonl"), &evs[42..])); + let st1 = init(persona, 2, &format!("cp-{tag}-one.json")); let one = tmp(&format!("cp-{tag}-one.strand")); clean(&[&one]); + let (c, out1, e) = live(persona, &st1, &all, &one, "10"); assert_eq!(c, 0, "{tag}: {e}"); + let st2 = init(persona, 2, &format!("cp-{tag}-two.json")); let two = tmp(&format!("cp-{tag}-two.strand")); clean(&[&two]); + let (c, o1, e) = live(persona, &st2, &head, &two, "10"); assert_eq!(c, 0, "{tag}: {e}"); + assert_eq!(read(&two).lines().filter(|l| l.starts_with(r#"{"checkpoint":"#)).count(), 4, "{tag}: 4 checkpoint lines"); + let (c, o2, e) = live(persona, &st2, &last, &two, "10"); assert_eq!(c, 0, "{tag}: resume after a checkpoint: {e}"); + assert_eq!(read(&two), read(&one), "{tag}: the strand"); assert_eq!(read(&st2), read(&st1), "{tag}: the state"); + assert_eq!(format!("{o1}{o2}"), out1, "{tag}: the stances"); + let (c, v, e) = probbit(&["live", "verify", &two]); assert_eq!(c, 0, "{tag}: {e}"); assert!(v.contains(r#""checkpoints":4"#), "{tag}: {v}"); + // a checkpoint line that is not valid still stops the resume, at that line: its count edited (the line stays canonical) + let text = read(&two); let k = text.lines().position(|l| l.starts_with(r#"{"checkpoint":20,"#)).unwrap(); + std::fs::write(&two, text.replacen(r#"{"checkpoint":20,"#, r#"{"checkpoint":21,"#, 1)).unwrap(); + let (c, o, _) = live(persona, &st2, &last, &two, "10"); assert_eq!(c, 2, "{tag}: {o}"); + assert!(o.contains(&format!("line {}: the line before is not event 21", k + 1)), "{tag}: {o}"); + clean(&[&all, &head, &last, &st1, &one, &st2, &two]); + } +} diff --git a/probbit-persona/src/strand.rs b/probbit-persona/src/strand.rs index 6887fa0..83016cd 100644 --- a/probbit-persona/src/strand.rs +++ b/probbit-persona/src/strand.rs @@ -323,25 +323,8 @@ impl Replay { let mut r = Replay::open(head)?; // RFC 0009: the expectation's memory is a fold of every event line, so a strand with the block replays from its header if r.live.p.expect.is_some() { return Err((at, "the persona's expectation block reads every event before this checkpoint: verify this strand from its header".into())); } - let j = json::parse(cp).map_err(|e| (at, format!("not JSON: {}", e.msg)))?; - let b = json::parse(before).map_err(|e| (at - 1, format!("not JSON: {}", e.msg)))?; - if persona::canon(&j) != cp { return Err((at, "not canonical JSON".into())); } - if let Some(k) = unknown_key(&j) { return Err((at, unsupported(k))); } - if !j.as_obj().is_some_and(|kv| kv.len() == 4 && kv.iter().all(|(k, _)| ["checkpoint", "prev", "stance", "state"].contains(&k.as_str()))) { - return Err((at, "checkpoint fields must be checkpoint, prev, stance and state".into())); - } - // §2.18: an event line of a persona with glyphs also carries `glyph` - if !b.as_obj().is_some_and(|kv| kv.len() == 5 + usize::from(kv.iter().any(|(k, _)| k == "glyph")) && kv.iter().all(|(k, _)| ["n", "inputs", "prev", "stance", "state", "glyph"].contains(&k.as_str()))) { - return Err((at - 1, "a checkpoint must immediately follow an event line".into())); - } - if j.get("prev").and_then(Json::as_str) != Some(persona::digest_of(before).as_str()) { return Err((at, "prev is not the sha256 of the line before".into())); } - let n = j.get("checkpoint").and_then(Json::as_f64).filter(|x| *x >= 1.0 && x.fract() == 0.0).ok_or((at, "not a checkpoint line".to_string()))? as u64; - if b.get("n").and_then(Json::as_f64) != Some(n as f64) { return Err((at, format!("the line before is not event {n}"))); } - let stance = j.get("stance").cloned().unwrap_or(Json::Null); - if b.get("stance").and_then(Json::as_str) != Some(persona::stance_sha(&stance).as_str()) { return Err((at, "the checkpoint's stance is not the one event {n} logs".replace("{n}", &n.to_string()))); } - let st = State::read(&r.live.p, j.get("state").unwrap_or(&Json::Null)).map_err(|e| (at, read_err(e, "the checkpoint's state: ")))?; - if b.get("state").and_then(Json::as_str) != Some(st.digest.as_str()) { return Err((at, format!("the checkpoint's state is not the one event {n} logs"))); } - r.live.st = st; r.live.n = n; r.live.prev = persona::digest_of(cp); r.live.checkpoints = 1; r.line = at; r.from = Some(n); r.last = Some(stance); r.checkpoint_ready = false; + let (n, stance, st) = check_checkpoint(&r.live.p, before, cp, at)?; + r.live.st = *st; r.live.n = n; r.live.prev = persona::digest_of(cp); r.live.checkpoints = 1; r.line = at; r.from = Some(n); r.last = Some(stance); r.checkpoint_ready = false; r.norms = Normals::start(&r.live.p, &r.live.st); r.at_cap = vec![0.0; r.at_cap.len()]; r.seen = (0.0, vec![0.0; r.seen.1.len()]); r.viab = viab_start(&r.live); Ok(r) } @@ -601,11 +584,32 @@ fn open_head(head: &str) -> Result<(Box, Json), (usize, String)> { if !same || at != hb.len() { return Err((1, "the header is not as written".into())); } Ok((p, h)) } -/// A checkpoint line opened as a replay starts there (`Replay::open_at`) -> its event count and its persona's digest; out of line, -/// so the chain check's frame does not hold the replay +/// A checkpoint line's own checks (`cp`, without its line ending, at 1-based line `at`; `before` = the line before it) for persona +/// `p`, with `open_at`'s messages: canonical JSON, its four fields, right after an event line (prev), at that event's count, with +/// the stance and the state (read as any state is) that event logs -> the count, the stance and the state. No replay starts here, +/// so it holds for a persona with an expectation block too: the chain check needs the line valid, not a replay start. #[inline(never)] -fn checkpoint_at(head: &str, before: &str, cp: &str, at: usize) -> Result<(u64, String), (usize, String)> { - let r = Replay::open_at(head, before, cp, at)?; Ok((r.live.n, r.live.p.digest.clone())) +fn check_checkpoint(p: &Persona, before: &str, cp: &str, at: usize) -> Result<(u64, Json, Box), (usize, String)> { + let j = json::parse(cp).map_err(|e| (at, format!("not JSON: {}", e.msg)))?; + let b = json::parse(before).map_err(|e| (at - 1, format!("not JSON: {}", e.msg)))?; + if persona::canon(&j) != cp { return Err((at, "not canonical JSON".into())); } + if let Some(k) = unknown_key(&j) { return Err((at, unsupported(k))); } + if !j.as_obj().is_some_and(|kv| kv.len() == 4 && kv.iter().all(|(k, _)| ["checkpoint", "prev", "stance", "state"].contains(&k.as_str()))) { + return Err((at, "checkpoint fields must be checkpoint, prev, stance and state".into())); + } + // §2.18: an event line of a persona with glyphs also carries `glyph`; RFC 0009: of a persona with an expectation block, `deviation` + let extra = |kv: &[(String, Json)]| kv.iter().filter(|(k, _)| k == "glyph" || k == "deviation").count(); + if !b.as_obj().is_some_and(|kv| kv.len() == 5 + extra(kv) && kv.iter().all(|(k, _)| ["n", "inputs", "prev", "stance", "state", "glyph", "deviation"].contains(&k.as_str()))) { + return Err((at - 1, "a checkpoint must immediately follow an event line".into())); + } + if j.get("prev").and_then(Json::as_str) != Some(persona::digest_of(before).as_str()) { return Err((at, "prev is not the sha256 of the line before".into())); } + let n = j.get("checkpoint").and_then(Json::as_f64).filter(|x| *x >= 1.0 && x.fract() == 0.0).ok_or((at, "not a checkpoint line".to_string()))? as u64; + if b.get("n").and_then(Json::as_f64) != Some(n as f64) { return Err((at, format!("the line before is not event {n}"))); } + let stance = j.get("stance").cloned().unwrap_or(Json::Null); + if b.get("stance").and_then(Json::as_str) != Some(persona::stance_sha(&stance).as_str()) { return Err((at, "the checkpoint's stance is not the one event {n} logs".replace("{n}", &n.to_string()))); } + let st = read_boxed(p, j.get("state").unwrap_or(&Json::Null)).map_err(|e| (at, read_err(e, "the checkpoint's state: ")))?; + if b.get("state").and_then(Json::as_str) != Some(st.digest.as_str()) { return Err((at, format!("the checkpoint's state is not the one event {n} logs"))); } + Ok((n, stance, st)) } #[inline(never)] fn read_boxed(p: &Persona, j: &Json) -> Result, InErr> { State::read(p, j).map(Box::new) } @@ -648,11 +652,10 @@ fn check_chain_head(text: &str) -> Result<(Status, Box, Json), InErr> { } Kind::Checkpoint => { if status != Status::Active { return Err(err(i + 1, format!("checkpoint while {}", status.name()))); } - // Full state validation plus links to the immediately preceding event; no inference. - let (cn, cd) = checkpoint_at(ls[0], ls[i - 1], ls[i], i + 1).map_err(|(at, why)| err(at, why))?; + // Full state validation plus links to the immediately preceding event; no inference and no replay start, so a + // persona with an expectation block (whose replay cannot start at a checkpoint) resumes after one as well + let (cn, _, _) = check_checkpoint(&hp, ls[i - 1], ls[i], i + 1).map_err(|(at, why)| err(at, why))?; if cn != n { return Err(err(i + 1, "checkpoint event count differs".into())); } - // Keep the header persona/state check independent of the caller's state. - debug_assert_eq!(cd, hp.digest); } } } From 26ebf4ec2bcc7710a5c89da0a4115b8d0c9f5158 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 02:58:50 -0700 Subject: [PATCH 12/61] probbit-persona, probbit-cli: checkpoints carry the expectation's memory and strands seal into segments (engine 0.14.0 on): the canonical fold (6 decimals; at most max_combos - 1 named combos, the one moved out adding its count to rest) rides on each checkpoint as expect, so verify --from-checkpoint, resume and monitor start at the last checkpoint and fold only the events after it; live --segment-every N writes NAME.0001.strand, ... (a header with segment {index, previous, sealed}, then the sealed line itself: the chain goes on across files); live verify checks a segment on its link, --chain walks back (exit 1 at a break naming the file, 6 when one is missing); strands of earlier engines byte for byte as before; docs, CHANGELOG --- CHANGELOG.md | 16 +++ docs/embedding.md | 29 ++++++ docs/persona.md | 46 ++++++++- docs/probbit.1 | 22 +++-- probbit-cli/src/live.rs | 106 ++++++++++++++++++++ probbit-cli/src/main.rs | 68 +++++++++++-- probbit-cli/src/monitor.rs | 23 +++-- probbit-cli/tests/live.rs | 41 ++++++++ probbit-persona/src/persona.rs | 64 +++++++++++- probbit-persona/src/strand.rs | 174 +++++++++++++++++++++++++-------- 10 files changed, 517 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9f94b7..52815f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,22 @@ All notable changes to this project are documented here. The format follows the same way, and `resume` opens the strand's header once (the chain check builds the persona on the heap and compares the header in pieces, without a replay). On the host proxy one event's stack high-water fell from 31,824 to 13,808 bytes and `resume`'s from 30,560 to 15,792; every stance document, strand line and digest is byte for byte as before. +- **Resume after a checkpoint with an expectation block**: the chain check that `resume` (and `probbit live ... --strand`) runs + validates each checkpoint line (its state read, its event count, its link to the event before) without opening it as a replay + start, which a persona with an `expectation` block cannot do; such a strand resumes after its first checkpoint (it exited 2 + before). Every line and digest as before. +- **Checkpoints carry the expectation's memory** (docs/persona.md section 5.7, engine 0.14.0 on): the canonical fold (every count, + the mean and the variance at 6 decimals; at most `max_combos - 1` named combos, the one moved out adding its count to `rest`, + the empty member's) and one more checkpoint key, `expect`, so `verify --from-checkpoint`, `resume` and `monitor` start at the + last checkpoint of a strand with the block and fold only the events after it. The line is O(max_combos) (6,369 B for the + test fixture at `max_combos` 16; 14,121 B at 64). Strands whose header names an engine before 0.14.0 keep the four-key + checkpoint and the earlier fold, byte for byte; an earlier engine reads `expect` as unsupported (exit 5). +- **Sealed segments** (docs/persona.md section 5.7, docs/embedding.md "Sizing a strand on flash"): `probbit live ... + --segment-every N` seals the strand with a checkpoint line every N events and goes on in `NAME.0001.strand`, ...: a header with + `segment` {index, previous, sealed} and the sealed line itself, so the chain continues across files. `live verify SEGMENT` + checks one segment on its link (summary `segment`, `trusted_link`); `live verify SEGMENT --chain` walks back to the first file + (exit 1 at a break, naming the file; exit 6 when a previous file is missing). `strand::segment_header`, + `strand::segment_name`, `strand::write_header_seg`, `strand::Replay::open_lines`. - **`turn_trace`** (feature of probbit-persona and probbit-cli, off by default): `persona::trace` records the last event's microseconds and a host probe's value per phase (input, expect, prep, compile, solve, decode, document, line, fold), so a firmware measures the same breakdown on its hardware; `probbit live` in such a build prints it on stderr when diff --git a/docs/embedding.md b/docs/embedding.md index 49949e2..4109d26 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -178,6 +178,35 @@ digits for the deviation receipt), so the device's strand and the host's replay includes the stance's `deviation` on checkpoint lines (`strand::DEV_R6_SINCE`). A strand whose header names an earlier engine keeps its full-precision deviation, and verify accepts it when it equals the replay at 6 decimals. +### Sizing a strand on flash: checkpoints and segments + +A strand grows by one event line per event and one checkpoint line every K events (`live.checkpoint`), and it never shrinks. +With E bytes per event line, C per checkpoint line and H for the header, n events take about H + n x (E + C / K) bytes. The +expectation fixture persona of the CLI's tests (a 6,312-byte header, a 10-input tutor with an `expectation` block, 500 events +over many input combos) measures E <= 498 B and, from engine 0.14.0 (the checkpoint carries the expectation's memory), +C = 6,369 B with `max_combos` 16 and C = 14,121 B with `max_combos` 64, the memory's largest window (63 named combos); before +0.14.0 the same checkpoint was 3,888 B and the memory it did not carry had grown to 387 combos. At K = 50 and one event a +minute that is about 37 KB an hour: 1 MB lasts about 27 hours. A larger persona scales both: one measured at about 0.85 KB per +event and 8.2 KB per checkpoint fills 1 MB in 3.5 to 4 hours at K = 50 and its event rate. + +Segments bound the file a life is stored in: `--segment-every S` (or a host's own seal: a checkpoint line, then +`strand::segment_header(&live, &doc, engine, k, previous_name, &sha256_of_that_line)` as the first line of a new file and the +checkpoint line as its second) costs one header and one repeated checkpoint per segment, so a segment file is about +H + C + S x (E + C / K) bytes (for the fixture at S = 500, K = 50: about 325 KB). A host that keeps the newest two segments and +deletes the older ones holds a life of any length in about twice that; `verify --chain` then reports the first missing file +(exit 6) instead of a divergence, and each kept segment still verifies on its link. Resume works on the newest segment alone: +`strand::resume` reads its header, its sealed line (the state and, from 0.14.0, the expectation's memory) and the lines after +it: a boot parses and hashes the lines of that one segment, folds the expectation's memory over the lines after its last +checkpoint only, and never solves a stored event again. + +### Measured on one board (ESP32-S3) + +First numbers from one ESP32-S3 at 240 MHz, the engine on one core shared with a 16 fps renderer, with the boxed setup path +above: the engine task's stack high-water for the setup plus one to three turns was 17,612 to 18,096 bytes (28,304 bytes for +the setup alone before the boxed constructors); the strand header was 11,985 bytes, byte for byte the host's; one stance turn +took 0.60 to 0.93 s with 17 to 23 KB of transient heap per event; the engine's PSRAM use was 50 to 112 KB during setup and run; +the internal heap's floor was 30,216 bytes through the header write. These are one board's measurements, not bounds. + ## The host hooks `probbit_persona::hooks` lets a host see the decision pipeline: `set_monitor(tier, gate_seen)` (the CLI's `--top` view) and diff --git a/docs/persona.md b/docs/persona.md index 2933d9d..804e73d 100644 --- a/docs/persona.md +++ b/docs/persona.md @@ -951,8 +951,10 @@ half-life; read after the cold start, from the logged value); `registers` = z >= `"deviation": {"input", "pred", "self"}` at 5 significant digits (with `"cold": true` while cold); `live verify` recomputes it ("the deviation differs" on an edit), `resume` folds the same memory from the logged lines, and the stance digest the line logs excludes `deviation`. Bound (by construction): per variable, `pred_i <= input` for a member combo and -`pred_i <= self_i + input` otherwise. `persona turn` and `persona replay` carry no memory and add nothing. A strand with -the block verifies from its header (`--from-checkpoint` refuses: the memory reads every event). The deviation is absent +`pred_i <= self_i + input` otherwise. `persona turn` and `persona replay` carry no memory and add nothing. Before engine 0.14.0 a +strand with the block verifies from its header (`--from-checkpoint` refuses: the memory reads every event); from 0.14.0 the +memory is the canonical fold and every checkpoint line carries it (section 5.7, "Checkpoints"), so a replay and a resume start +at the last checkpoint. The deviation is absent (`null`) when a program is not solved by the exact tier. Every number of the stance's `deviation` is rounded to 6 decimals (the rule every other number of a stance and a state @@ -1547,6 +1549,42 @@ replays only what follows (`"from_checkpoint": n`); the lines before it are trus number a checkpoint line carries is rounded (6 decimals; genes 4), so the line is the same on every platform's libm; for the stance's `deviation` this holds from engine 0.14.0 on (section 2.17). +**Checkpoints with the expectation's memory (engine 0.14.0 on).** A persona with an `expectation` block (RFC 0009) folds every +event into a memory: per input combo its decayed count and the inputs of its latest event, whether a non-time combo was seen, +and the learned normal of the deviation. From 0.14.0 the fold is canonical: after every event each count, the mean and the +variance are at 6 decimals (a count at 0 is dropped), and at most `max_combos - 1` named combos are kept: when one more enters, +the last in the mixture's order (count descending, then key) moves its count to `rest`, which decays like a count and is added to +the empty member's weight, where the mixture already put every combo past the kept ones (the one change: a combo that comes +back after it was moved out starts at count 1). The checkpoint line then carries the memory as one more key: +`"expect": {"combos": [[count, inputs], ...], "mean", "n", "rest", "var", "warm"}` (combos in key order; the key is computed from +the inputs). A full `verify` checks it byte for byte against the replay's; `verify --from-checkpoint`, `resume` and `monitor` +start at the last checkpoint and fold only the events after it. The line's size is O(max_combos): the `expect` object is at +most about 64 + (max_combos - 1) x (I + 24) bytes for events whose inputs are at most I bytes. A strand whose header names an +engine before 0.14.0 keeps the four-key checkpoint and the fold before it, byte for byte; such a strand with the block still +replays from its header. An engine before 0.14.0 reads `expect` as `unsupported by this engine: expect` (exit 5). + +**Segments.** `probbit live ... --strand NAME.strand --segment-every N` seals the strand after every N-th event and goes on in +a new file: the sealing line is a checkpoint line (the last line of the segment); the next file, `NAME.0001.strand`, then +`NAME.0002.strand` and so on (four digits at least), starts with a header that has one more key at its end, +`"segment": {"index": k, "previous": "", "sealed": "sha256:"}`, its state the +sealed state, and its second line is the sealing line itself, byte for byte. The chain goes on across the files unchanged: the +lines of all segments, each segment's header and repeated sealing line set aside, are the strand the same life writes to one +file with a checkpoint at each seal. The labels sidecar is the first file's (`NAME.labels.json`); a run with `--segment-every` +continues the newest segment that exists. Before 0.14.0 a persona with an expectation block is not segmented (its checkpoint +does not carry the memory). + +- `live verify SEGMENT` checks one segment on its link: the header is as written, the second line's sha256 is the header's + `sealed`, that line is a valid checkpoint (its state is the header's, its memory reads), and every line after it replays. + Exit 0 says: this file continues, faithfully, whatever line has that sha256. The summary adds `"segment": k` and + `"trusted_link": "sha256:..."`. +- `live verify SEGMENT --chain` also walks back through `previous` to the first file: each one verifies, its last line's + sha256 is the `sealed` the next one links and its index is one less. Exit 1 at the first break, with `"segment_file"` + naming the file; exit 6 when a previous file does not exist (`{"ok": false, "missing": "", "segment_file": ""}`): a segment rotated away is a known state, not a divergence. The summary adds `"segments": N`. + +Compaction (replacing old segments by one synthetic start) is not part of the format: it would drop the history a verify +checks, and the decision to trust a dropped history belongs to the host. + **Senses in live** (section 2.11). An event carries `senses` as `persona turn` does; the strand logs each reading as used, with its source, and `verify` recomputes the normals from the logged readings: its summary adds `"normals": {"": {"mean", "n", "max_move_50"}}`, the normal now and the largest move of its mean within 50 events (the monitor alerts above drift_cap / 2). A @@ -1583,7 +1621,7 @@ check that inference. Hashes alone do not authenticate a writer or detect a whol anchor. Historical measurement before this hardening, on an Apple M4: a 10,000-event strand of a drives persona (4 goals, a floor, learning; 3,126,444 bytes, 10 checkpoint lines) opens in `monitor --once` from its last checkpoint in 0.005 s (load 3.3), where a full `verify` takes 192 s. A replay from a checkpoint costs what the events after it cost: 500 events past the last one took 26 s to draw -(load 6-13, about 50 ms an event for this persona), so K bounds the wait; a smaller K costs a few KB per checkpoint line. A persona with an `expectation` block (RFC 0009) +(load 6-13, about 50 ms an event for this persona), so K bounds the wait; a smaller K costs a few KB per checkpoint line. Before engine 0.14.0 a persona with an `expectation` block (RFC 0009) is the exception: its expectation memory is a fold of every event line, and no checkpoint line carries it, so `monitor` (`--once`, `--follow`, `--serve`, each strand of `--dir` by its own persona) replays such a strand from its header, every line, and `verify --from-checkpoint` still refuses it. The cost is a full replay when the strand is opened: on a 16-vCPU Linux VM, a 5,000-event life of the tutor with the block (10 checkpoint lines) opens in `monitor --once` in 7.4 to 7.8 s, about 650 events a second, where the same life without the block opens from its last checkpoint in under 0.05 s; after @@ -1830,7 +1868,7 @@ previous event, the restart included). `probbit_live_event` (MCP) and Python's ` | `probbit persona compile PERSONA --state STATE [--inputs ...]` | the turn's probbit-ir program; its sha256 is the stance's `engine.program` | | `probbit live PERSONA [--seed N \| --state FILE] [--strand FILE] [--events FILE [--watch]] [--clock real\|fixed]` | a resident individual: JSONL events in, one stance per event out, `elapsed_hours` stamped by the clock, the life logged to a strand (section 5.7); `--state` is rewritten after every event | | `probbit live PERSONA --demo week [--seed N] [--strand FILE] [--plain]` | one individual's scripted week on the fixed clock: learning to its cap, a quiet night, habits that hold (section 5.7) | -| `probbit live verify STRAND [--from-checkpoint] [--actions FILE]` | replay a strand from its header alone: ok, the earliest line that differs (exit 1), or the earliest line with a field or line kind this engine does not know (`unsupported`, exit 5: a newer engine's strand, not a divergence); with `--actions`, also the earliest turn whose host action differs from the level its stance released (section 5.7) | +| `probbit live verify STRAND [--from-checkpoint] [--chain] [--actions FILE]` | replay a strand from its header alone: ok, the earliest line that differs (exit 1), or the earliest line with a field or line kind this engine does not know (`unsupported`, exit 5: a newer engine's strand, not a divergence); with `--actions`, also the earliest turn whose host action differs from the level its stance released; with `--chain`, a segment and every previous segment file (exit 6: one is missing) (section 5.7) | | `probbit monitor STRAND [--follow] [--once] [--plain] [--fps N] [--serve] [--open] [--port N]`, `probbit monitor --demo [...]` | watch an individual's inner state: the strand replayed as live bars in the terminal, or as a page on 127.0.0.1 (section 5.8) | A script is a JSON list of per-turn input objects (or `{"turns": [...]}`; a turn may be `{"inputs": {...}, ...}`). Exit codes diff --git a/docs/probbit.1 b/docs/probbit.1 index 308f4de..70f2a8c 100644 --- a/docs/probbit.1 +++ b/docs/probbit.1 @@ -7,11 +7,11 @@ probbit \- a virtual probabilistic (p\-bit) processor: joint decisions under har (SUB: init turn replay explain diff lint fuzz prove check compile describe) \fBprobbit\fR live PERSONA [\-\-seed N | \-\-state FILE] [\-\-strand FILE [\-\-label KEY=VALUE]...] [\-\-events FILE [\-\-watch]] - [\-\-clock real|fixed] [\-\-checkpoint\-every K] + [\-\-clock real|fixed] [\-\-checkpoint\-every K] [\-\-segment\-every N] \fBprobbit\fR live PERSONA \-\-demo week [\-\-seed N] [\-\-strand FILE [\-\-label KEY=VALUE]...] [\-\-plain] -\fBprobbit\fR live verify STRAND [\-\-from\-checkpoint] [\-\-actions FILE] - [\-\-blocks FILE] +\fBprobbit\fR live verify STRAND [\-\-from\-checkpoint] [\-\-chain] + [\-\-actions FILE] [\-\-blocks FILE] \fBprobbit\fR live control STRAND pause|resume|retire|erase \-\-by WHO \-\-reason TEXT [\-\-at TIME] \fBprobbit\fR check PERSONA [\-\-props FILE] [\-\-strand FILE]... [\-\-seeds LIST] @@ -294,11 +294,11 @@ a resident individual: JSONL events in, one stance per event out .nf \fBprobbit\fR live PERSONA [\-\-seed N | \-\-state FILE] [\-\-strand FILE [\-\-label KEY=VALUE]...] [\-\-events FILE [\-\-watch]] - [\-\-clock real|fixed] [\-\-checkpoint\-every K] + [\-\-clock real|fixed] [\-\-checkpoint\-every K] [\-\-segment\-every N] \fBprobbit\fR live PERSONA \-\-demo week [\-\-seed N] [\-\-strand FILE [\-\-label KEY=VALUE]...] [\-\-plain] -\fBprobbit\fR live verify STRAND [\-\-from\-checkpoint] [\-\-actions FILE] - [\-\-blocks FILE] +\fBprobbit\fR live verify STRAND [\-\-from\-checkpoint] [\-\-chain] + [\-\-actions FILE] [\-\-blocks FILE] \fBprobbit\fR live control STRAND pause|resume|retire|erase \-\-by WHO \-\-reason TEXT [\-\-at TIME] .fi @@ -337,6 +337,9 @@ no colour, no animation (as NO_COLOR=1 or PROBBIT_THEME=plain); output otherwise \fB\-\-checkpoint\-every\fR \fIK\fR with \-\-strand: a checkpoint line (the whole state) after every K\-th event (default 1000; 0: none); `verify \-\-from\-checkpoint` and `monitor` start at the last one .TP +\fB\-\-segment\-every\fR \fIN\fR +with \-\-strand NAME.strand: seal the strand after every N\-th event (a sealing checkpoint line) and go on in the next segment file NAME.0001.strand, NAME.0002.strand, ...: its header links the previous file (its name, the sealed line's sha256, the index) and its second line is the sealed line, so the chain continues across files; a run continues the newest segment (default 0: one file) +.TP \fB\-\-label\fR \fIKEY=VALUE\fR with \-\-strand, repeatable: a presentation label for the monitor (agent, host, lane, session, or a free key; at most 8 keys, values of 1 to 64 printable characters) merged into the strand's sidecar FILE.labels.json (FILE without its extension); the strand itself, its chain and its digests are not touched .RE @@ -344,13 +347,16 @@ with \-\-strand, repeatable: a presentation label for the monitor (agent, host, Subcommands: .RS 4 .TP -\fBverify\fR STRAND [\-\-from\-checkpoint] [\-\-actions FILE] [\-\-blocks FILE] +\fBverify\fR STRAND [\-\-from\-checkpoint] [\-\-chain] [\-\-actions FILE] [\-\-blocks FILE] replay a strand: the earliest line that differs (exit 1), or the earliest line with a field or line kind this engine does not know (`unsupported by this engine: FIELD`, exit 5: a newer engine's strand, not a divergence); from the header (every checkpoint checked), or from the last checkpoint; with \-\-actions, also the earliest turn whose host action differs from the level its stance released .RS 4 .TP \fB\-\-from\-checkpoint\fR start at the last checkpoint instead of the header .TP +\fB\-\-chain\fR +a segment (section 5.7): verify it and walk back through the previous segment files to the first, each verified and its last line the sealed line the next one links; exit 1 at the first break (the segment file named), 6 when a previous segment file is missing +.TP \fB\-\-actions\fR \fIFILE\fR the host's action log, one {"turn": T, "action": {trait: level}} per line: compared with each turn's released levels (summary `actions`: checked, differs); exit 1 when one differs .TP @@ -375,7 +381,7 @@ when (default: now, UTC) .PP \fIthe safety kit\fR: (docs/persona.md \[u00A7]5.7) one writer per strand: `\-\-strand` takes STRAND.lock (a second writer exits 4 and changes nothing; a lock whose process is gone is taken over). With `reward_from` in the persona (\[u00A7]2.10), a reward from `src: self`, without a src or from an undeclared one is refused like a bad event. .PP -\fIexit\fR: 0 done (live: every event answered; verify: every line replays; control: the line appended), 1 verify: a line differs (its number and what) or, with \-\-actions, a host action differs from its turn's released level, 2 a bad persona, state, event (one {"error"} object on stdout per bad event when stdout is not a terminal or with \-\-json; the run goes on), control line or flag, 4 refused: the strand's writer lock is held by another writer, or the individual is paused or retired (one {"error"} object per refused event, as for 2; nothing is written), 5 verify: unsupported, a line has a field or line kind this engine does not know (a newer engine's strand: {"ok": false, "line", "unsupported": the field, "engine", "strand_engine"}; not verified, and not a divergence). +\fIexit\fR: 0 done (live: every event answered; verify: every line replays; control: the line appended), 1 verify: a line differs (its number and what) or, with \-\-actions, a host action differs from its turn's released level, 2 a bad persona, state, event (one {"error"} object on stdout per bad event when stdout is not a terminal or with \-\-json; the run goes on), control line or flag, 4 refused: the strand's writer lock is held by another writer, or the individual is paused or retired (one {"error"} object per refused event, as for 2; nothing is written), 5 verify: unsupported, a line has a field or line kind this engine does not know (a newer engine's strand: {"ok": false, "line", "unsupported": the field, "engine", "strand_engine"}; not verified, and not a divergence), 6 verify \-\-chain: a previous segment file is missing ({"ok": false, "missing": its file name, "segment_file": the segment that links it}). .PP Examples: .RS 4 diff --git a/probbit-cli/src/live.rs b/probbit-cli/src/live.rs index 0f158fe..e4d5663 100644 --- a/probbit-cli/src/live.rs +++ b/probbit-cli/src/live.rs @@ -364,6 +364,112 @@ mod tests { persona::expect_step(&pn, &mut mem, e, d.logged_pred()); st = persona::turn(&pn, &st, e, false, &run, false).unwrap().1; } } + /// 0.14.0, the canonical fold (docs/persona.md §5.7): a checkpoint of a persona with an expectation block carries the memory + /// (`expect`), so `verify --from-checkpoint` and `resume` start there: the life with a checkpoint after every 7th event gives + /// the stances, the state and the memory of the life without them, verifies from its header and from its last checkpoint to + /// one summary, and a resume writes the continuous life's next line; the memory never holds more than max_combos - 1 named + /// combos; an edited memory diverges at its checkpoint; a 0.13.0 strand keeps the four-key checkpoint and the refusal + #[test] + fn a_checkpoint_carries_the_expectation_memory() { + let mut on = json::parse(DOC).unwrap(); + if let Json::Obj(v) = &mut on { v.push(("expectation".into(), json::parse(r#"{"max_combos":3}"#).unwrap())); } + let p = persona::build(&on).unwrap(); + let evs: Vec = (0..60).map(|t| json::parse(&format!(r#"{{"praise":{},"criticism":{},"loss":{},"elapsed_hours":0.5}}"#, t % 2 == 1, t % 3 == 1, t % 5 == 4)).unwrap()).collect(); + let life = |engine: &str, every: u64| { let (mut lv, head) = Live::start(p.clone(), &on, persona::init(&p, Some(2), true, &run), Clock::Fixed, engine); + let (mut text, mut out) = (format!("{head}\n"), vec![]); + for e in &evs { let (s, l) = lv.event(e, &run).unwrap(); text += &l; text.push('\n'); if every > 0 && lv.n % every == 0 { text += &lv.checkpoint(&s); text.push('\n'); } out.push(s); } + (text, out, lv) }; + let ((_, s0, l0), (cp, s1, l1)) = (life("probbit 0.14.0", 0), life("probbit 0.14.0", 7)); + assert_eq!(s0, s1, "checkpoints move no stance"); let mem = |l: &Live| l.expect.as_ref().map(persona::ExpectMem::to_json); + assert_eq!(l0.st.digest, l1.st.digest); assert_eq!(mem(&l0), mem(&l1)); + let ls: Vec<&str> = cp.lines().collect(); let cps: Vec = (0..ls.len()).filter(|&i| ls[i].starts_with(r#"{"checkpoint":"#)).collect(); assert_eq!(cps.len(), 8); + for &i in &cps { let j = json::parse(ls[i]).unwrap(); let m = persona::ExpectMem::read(&p, j.get("expect").expect("expect on a 0.14.0 checkpoint")).unwrap(); + assert!(m.to_json().get("combos").and_then(Json::as_arr).unwrap().len() <= 3, "{}", ls[i]); } + let (full, fast) = (verify(&cp, &run).unwrap(), verify_from_checkpoint(&cp, &run).unwrap()); + assert_eq!(fast.get("from_checkpoint"), Some(&Json::Num(56.0))); + for k in ["events", "final_state", "last_line"] { assert_eq!(full.get(k), fast.get(k), "{k}"); } + // resume from the text before the last event (the last checkpoint in it) and the state there: the continuous life's line + let cut: String = ls[..ls.len() - 1].iter().map(|l| format!("{l}\n")).collect(); + let st = { let mut r = Replay::open(ls[0]).unwrap(); for l in &ls[1..ls.len() - 1] { r.step(l, &run).unwrap(); } r.live.st.clone() }; + let mut lv = resume(&cut, &p, st, Clock::Fixed).unwrap(); let (_, last) = lv.event(&evs[59], &run).unwrap(); + assert_eq!(last, ls[ls.len() - 1]); assert_eq!(mem(&lv), mem(&l1)); + // an edited memory (the chain re-made after it) diverges at its checkpoint in a full verify + let at = cps[3]; let mut j = json::parse(ls[at]).unwrap(); let n = j.get("expect").and_then(|e| e.get("n")).and_then(Json::as_f64).unwrap(); + put(&mut j, &["expect", "n"], Json::Num(n + 1.0)); let mut tt: Vec = ls.iter().map(|l| l.to_string()).collect(); tt[at] = persona::canon(&j); + assert_eq!(verify(&rechain(tt, at), &run).unwrap_err(), (at + 1, "the checkpoint's state or stance differs from the replay".to_string())); + // before 0.14.0: four keys, and a replay from a checkpoint refuses + let (old, _, _) = life("probbit 0.13.0", 7); + assert!(old.lines().filter(|l| l.starts_with(r#"{"checkpoint":"#)).all(|l| !l.contains(r#""expect":"#))); + assert!(verify_from_checkpoint(&old, &run).unwrap_err().1.contains("expectation block reads every event")); + assert_eq!(verify(&old, &run).map(|d| d.get("checkpoints").cloned()), Ok(Some(Json::Num(8.0)))); + } + + /// Event t of the S1 lives on the expectation fixture persona (tests/fixtures/monitor/tutor-expect.yaml): a hash of t picks the + /// sentiment, six flags and two numbers' thirds, so the lives see many input combos + fn s1_event(t: usize) -> Json { + let h = (t as u64).wrapping_mul(2654435761) >> 7; let b = |k: u64| (h >> k) & 1 == 1; + json::parse(&format!(r#"{{"sentiment":"{}","confused":{},"error":{},"loss":{},"praise":{},"criticism":{},"claim_done":{},"stakes":{},"time_pressure":{},"elapsed_hours":{}}}"#, + ["negative", "neutral", "positive"][(h % 3) as usize], b(2), b(3), b(4) && b(5), b(6), b(7) && b(8), b(9) && b(10), [0.1, 0.5, 0.9][((h >> 11) % 3) as usize], + [0.0, 0.5, 0.9][((h >> 13) % 3) as usize], [0.1, 0.5, 2.0][((h >> 15) % 3) as usize])).unwrap() + } + /// Segments with an expectation block (0.14.0, docs/persona.md §5.7): 500 events of the expectation fixture persona logged as one + /// file with a checkpoint after every 50th, and as segments of 50 events (each file: a header linking the previous one, the + /// sealed line, the events, its own sealed line): every stance document and the final state are the same, the lines joined + /// (each segment's header and repeated sealed line set aside) are the one file's, each segment verifies from its link to the + /// final state of the one file, a resume in the newest segment writes the one file's next line, and a segment whose second + /// line is not its header's sealed line diverges at line 2 + #[test] + fn segments_of_an_expectation_life_equal_one_file() { + let (p, doc) = persona::load(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/monitor/tutor-expect.yaml")).unwrap(); assert!(p.expect.is_some()); + let start = || Live::start(p.clone(), &doc, persona::init(&p, Some(2), true, &run), Clock::Fixed, "probbit 0.14.0"); + let ((mut a, ha), (mut b, hb)) = (start(), start()); + let (mut one, mut segs, mut sa, mut sb) = (format!("{ha}\n"), vec![format!("{hb}\n")], vec![], vec![]); + for t in 0..500 { + let (s, l) = a.event(&s1_event(t), &run).unwrap(); one += &l; one.push('\n'); if a.n % 50 == 0 { one += &a.checkpoint(&s); one.push('\n'); } sa.push(persona::canon(&s)); + let (s, l) = b.event(&s1_event(t), &run).unwrap(); let cur = segs.last_mut().unwrap(); *cur += &l; cur.push('\n'); + if b.n % 50 == 0 { let c = b.checkpoint(&s); *cur += &c; cur.push('\n'); let k = segs.len() as u64; + let h = segment_header(&b, &doc, "probbit 0.14.0", k, &segment_name("life.strand", k - 1), &persona::digest_of(&c)); segs.push(format!("{h}\n{c}\n")); } + sb.push(persona::canon(&s)); } + assert_eq!(sa, sb, "every stance document"); assert_eq!(a.st.digest, b.st.digest); assert_eq!(segs.len(), 11); + let mut joined: Vec<&str> = segs[0].lines().collect(); for t in &segs[1..] { joined.extend(t.lines().skip(2)); } + assert_eq!(joined, one.lines().collect::>(), "the segments are the one file"); + let fin = verify(&one, &run).unwrap(); assert_eq!(fin.get("events"), Some(&Json::Num(500.0))); + for (k, t) in segs.iter().enumerate() { let v = verify(t, &run).unwrap_or_else(|e| panic!("segment {k}: {e:?}")); + if k > 0 { assert_eq!(v.get("segment"), Some(&Json::Num(k as f64))); } if k == 10 { assert_eq!(v.get("final_state"), fin.get("final_state")); } } + let mut r = resume(&segs[10], &p, b.st.clone(), Clock::Fixed).unwrap(); let e = s1_event(500); + assert_eq!(r.event(&e, &run).unwrap().1, a.event(&e, &run).unwrap().1, "a resume in the newest segment"); + let mut l3: Vec<&str> = segs[3].lines().collect(); let other = segs[4].lines().nth(1).unwrap(); l3[1] = other; + assert_eq!(verify(&(l3.join("\n") + "\n"), &run).unwrap_err(), (2, SEALED.to_string())); + } + + /// S1 measurements (ignored: run with --ignored --nocapture): the expectation fixture persona, 500 events, a checkpoint every + /// 50: checkpoint line bytes (max_combos 16 and 64), and resume's time at 166 and 500 events, 0.13.0 (fold from the header) + /// against 0.14.0 (fold from the last checkpoint), with the full replay for scale + #[test] + #[ignore] + fn s1_measure() { + let y = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/monitor/tutor-expect.yaml")).unwrap(); + let doc0 = probbit_persona::yaml::load(&y).unwrap_or_else(|e| panic!("{}", e.msg)); + let ev = s1_event; + for mc in [16usize, 64] { + let mut d = doc0.clone(); put(&mut d, &["expectation"], json::parse(&format!(r#"{{"max_combos":{mc}}}"#)).unwrap()); let p = persona::build(&d).unwrap(); + for engine in ["probbit 0.13.0", "probbit 0.14.0"] { + let (mut lv, head) = Live::start(p.clone(), &d, persona::init(&p, Some(2), true, &run), Clock::Fixed, engine); + let (mut text, mut ev_b, mut cp_max, mut sts) = (format!("{head}\n"), 0usize, 0usize, vec![]); + for t in 0..500 { let (s, l) = lv.event(&ev(t), &run).unwrap(); ev_b = ev_b.max(l.len()); text += &l; text.push('\n'); + if lv.n % 50 == 0 { let c = lv.checkpoint(&s); cp_max = cp_max.max(c.len()); text += &c; text.push('\n'); } if lv.n == 166 || lv.n == 500 { sts.push((text.clone(), lv.st.clone())); } } + let named = lv.expect.as_ref().map(|m| m.to_json().get("combos").and_then(Json::as_arr).map_or(0, |a| a.len())).unwrap_or(0); + println!("S1 max_combos={mc} engine={engine}: header {} B, event line max {ev_b} B, checkpoint line max {cp_max} B, memory entries at 500: {named}", head.len()); + for (n, (t, st)) in [166usize, 500].into_iter().zip(&sts) { + let t0 = std::time::Instant::now(); let r = resume(t, &p, st.clone(), Clock::Fixed).unwrap(); let dt = t0.elapsed(); drop(r); + let t1 = std::time::Instant::now(); let v = verify(t, &run).unwrap(); let dv = t1.elapsed(); assert_eq!(v.get("events"), Some(&Json::Num(n as f64))); + let t2 = std::time::Instant::now(); let f = verify_from_checkpoint(t, &run); let df = t2.elapsed(); + println!("S1 n={n}: resume {:.1} ms, verify from header {:.1} ms, verify --from-checkpoint {} ({:.1} ms)", dt.as_secs_f64() * 1e3, dv.as_secs_f64() * 1e3, + if f.is_ok() { "ok" } else { "refused" }, df.as_secs_f64() * 1e3); } + } + } + } + /// §2.18: a persona with two moods and a habit that binds on a loss const GDOC: &str = r#"{"probbit_persona":1,"identity":{"name":"G","version":"1","seed":5}, "traits":[{"id":"humour","levels":["none","light","playful"],"logw":[0,1.0,0.8]},{"id":"caution","levels":["bold","measured","careful"],"logw":[0.3,0.4,0]}], diff --git a/probbit-cli/src/main.rs b/probbit-cli/src/main.rs index e3b60b4..562c86f 100644 --- a/probbit-cli/src/main.rs +++ b/probbit-cli/src/main.rs @@ -239,7 +239,7 @@ const F_P_SEEDS: Flag = Flag { name: "--seeds", arg: "LIST", help: "the individu const F_P_THREADS: Flag = Flag { name: "--threads", arg: "N", help: "search threads, 1 to 1024 (default: the logical CPUs)" }; const F_P_JSON: Flag = Flag { name: "--json", arg: "", help: "the JSON document instead of the readable report" }; const EXIT_PERSONA: &str = "0 done (every turn status, refusals and fallbacks included, is an answer; fuzz: no counterexample found; prove: every rule held or proved), 1 lint found an unresolved contradiction or (with rules) a counterexample, fuzz found a counterexample or prove left a rule unknown, 2 bad persona / state / inputs / script / rule (one {\"error\"} object on stdout, code \"persona\", when stdout is not a terminal or with --json) or bad flag (stderr), 3 lint (with rules): a rule the bound leaves unknown and the search cannot break (\"unproven\", not broken)."; -const EXIT_LIVE: &str = "0 done (live: every event answered; verify: every line replays; control: the line appended), 1 verify: a line differs (its number and what) or, with --actions, a host action differs from its turn's released level, 2 a bad persona, state, event (one {\"error\"} object on stdout per bad event when stdout is not a terminal or with --json; the run goes on), control line or flag, 4 refused: the strand's writer lock is held by another writer, or the individual is paused or retired (one {\"error\"} object per refused event, as for 2; nothing is written), 5 verify: unsupported, a line has a field or line kind this engine does not know (a newer engine's strand: {\"ok\": false, \"line\", \"unsupported\": the field, \"engine\", \"strand_engine\"}; not verified, and not a divergence)."; +const EXIT_LIVE: &str = "0 done (live: every event answered; verify: every line replays; control: the line appended), 1 verify: a line differs (its number and what) or, with --actions, a host action differs from its turn's released level, 2 a bad persona, state, event (one {\"error\"} object on stdout per bad event when stdout is not a terminal or with --json; the run goes on), control line or flag, 4 refused: the strand's writer lock is held by another writer, or the individual is paused or retired (one {\"error\"} object per refused event, as for 2; nothing is written), 5 verify: unsupported, a line has a field or line kind this engine does not know (a newer engine's strand: {\"ok\": false, \"line\", \"unsupported\": the field, \"engine\", \"strand_engine\"}; not verified, and not a divergence), 6 verify --chain: a previous segment file is missing ({\"ok\": false, \"missing\": its file name, \"segment_file\": the segment that links it})."; pub(crate) static COMMANDS: &[Command] = &[ Command { name: "persona", aliases: &[], group: "persona", summary: "the individuality layer: persona + state + inputs -> one stance", usage: &["persona SUB PERSONA [flags] (SUB: init turn replay explain diff lint fuzz prove check compile describe)"], @@ -294,9 +294,9 @@ pub(crate) static COMMANDS: &[Command] = &[ "probbit persona lint examples/persona/tutor.yaml"], run: persona_cmd }, Command { name: "live", aliases: &[], group: "live", summary: "a resident individual: JSONL events in, one stance per event out", - usage: &["live PERSONA [--seed N | --state FILE] [--strand FILE [--label KEY=VALUE]...] [--events FILE [--watch]] [--clock real|fixed] [--checkpoint-every K]", + usage: &["live PERSONA [--seed N | --state FILE] [--strand FILE [--label KEY=VALUE]...] [--events FILE [--watch]] [--clock real|fixed] [--checkpoint-every K] [--segment-every N]", "live PERSONA --demo week [--seed N] [--strand FILE [--label KEY=VALUE]...] [--plain]", - "live verify STRAND [--from-checkpoint] [--actions FILE] [--blocks FILE]", + "live verify STRAND [--from-checkpoint] [--chain] [--actions FILE] [--blocks FILE]", "live control STRAND pause|resume|retire|erase --by WHO --reason TEXT [--at TIME]"], about: "A resident individual (docs/persona.md §5.7): JSONL events (one object of inputs per line) from --events FILE or stdin, one stance per event on stdout (canonical JSON). The clock stamps each event's elapsed_hours (quantised to 1e-6 h), so moods decay by their half-lives between events; feedback moves the learned deltas of a persona with a learning block (§2.8).\n--strand FILE logs the life: a header (persona document, initial state, engine version), then per event the inputs as used, the stance and state digests and the sha256 of the line before. `probbit live verify STRAND` replays it.", before: &[], @@ -310,11 +310,13 @@ pub(crate) static COMMANDS: &[Command] = &[ Flag { name: "--demo", arg: "week", help: "one individual's scripted week on the fixed clock (7 days, events hourly 09:00-15:00): praise for short answers moves the learned verbosity deltas to their cap, a quiet night relaxes the mood to its resting level, a campaign praising jokes raises humour while failure turns stay joke-free; a persona without a learning block gets the demo's (said in the opening line). Bars on stderr at a terminal, paced 1 s per hour (nights fast-forward in 2 s); otherwise one line per event on stdout, no waiting. With --seed, --strand" }, F_PLAIN, Flag { name: "--checkpoint-every", arg: "K", help: "with --strand: a checkpoint line (the whole state) after every K-th event (default 1000; 0: none); `verify --from-checkpoint` and `monitor` start at the last one" }, + Flag { name: "--segment-every", arg: "N", help: "with --strand NAME.strand: seal the strand after every N-th event (a sealing checkpoint line) and go on in the next segment file NAME.0001.strand, NAME.0002.strand, ...: its header links the previous file (its name, the sealed line's sha256, the index) and its second line is the sealed line, so the chain continues across files; a run continues the newest segment (default 0: one file)" }, Flag { name: "--label", arg: "KEY=VALUE", help: "with --strand, repeatable: a presentation label for the monitor (agent, host, lane, session, or a free key; at most 8 keys, values of 1 to 64 printable characters) merged into the strand's sidecar FILE.labels.json (FILE without its extension); the strand itself, its chain and its digests are not touched" }, ]], subs: &[ - Sub { name: "verify", args: "STRAND [--from-checkpoint] [--actions FILE] [--blocks FILE]", help: "replay a strand: the earliest line that differs (exit 1), or the earliest line with a field or line kind this engine does not know (`unsupported by this engine: FIELD`, exit 5: a newer engine's strand, not a divergence); from the header (every checkpoint checked), or from the last checkpoint; with --actions, also the earliest turn whose host action differs from the level its stance released", + Sub { name: "verify", args: "STRAND [--from-checkpoint] [--chain] [--actions FILE] [--blocks FILE]", help: "replay a strand: the earliest line that differs (exit 1), or the earliest line with a field or line kind this engine does not know (`unsupported by this engine: FIELD`, exit 5: a newer engine's strand, not a divergence); from the header (every checkpoint checked), or from the last checkpoint; with --actions, also the earliest turn whose host action differs from the level its stance released", flags: &[Flag { name: "--from-checkpoint", arg: "", help: "start at the last checkpoint instead of the header" }, + Flag { name: "--chain", arg: "", help: "a segment (section 5.7): verify it and walk back through the previous segment files to the first, each verified and its last line the sealed line the next one links; exit 1 at the first break (the segment file named), 6 when a previous segment file is missing" }, Flag { name: "--actions", arg: "FILE", help: "the host's action log, one {\"turn\": T, \"action\": {trait: level}} per line: compared with each turn's released levels (summary `actions`: checked, differs); exit 1 when one differs" }, Flag { name: "--blocks", arg: "FILE", help: "write each event's block (the text a host injects, RFC 0008 E: every trait and mood as `name level`, then the viability line) to FILE, one {\"block\", \"n\"} per line; replays from the header" }] }, Sub { name: "control", args: "STRAND pause|resume|retire|erase --by WHO --reason TEXT [--at TIME]", @@ -822,6 +824,37 @@ fn live_cmd(args: &[String]) { Err((line, why)) => failed(line, &why) } return; } + // --chain (§5.7, segments): this segment, then each previous one back to the first, linked by the sealed line + if args.iter().any(|a| a == "--chain") { + if args.iter().any(|a| a == "--from-checkpoint") { fail("live verify: --chain replays every segment from its start: drop --from-checkpoint") } + let dir = std::path::Path::new(path).parent().map(std::path::Path::to_path_buf).unwrap_or_default(); + let name = |p: &str| std::path::Path::new(p).file_name().map_or_else(|| p.to_string(), |f| f.to_string_lossy().into_owned()); + let (mut cur, mut t, mut segs, mut newest) = (name(path), text.clone(), 0u64, None::); + let broke = |file: &str, d: Json, code: i32| -> ! { let mut d = d; if let Json::Obj(v) = &mut d { v.push(("segment_file".into(), Json::Str(file.to_string()))); } + emit(&persona::canon(&d)); exit(code) }; + loop { + match live::verify(&t, eng) { + Ok(d) => { if newest.is_none() { newest = Some(d); } segs += 1; } + Err((line, why)) => { err_line(&format!("probbit: live verify --chain: {cur}: line {line}: {why}")); + broke(&cur, live::failure(&t, line, &why), if live::unsupported_field(&why).is_some() { 5 } else { 1 }) } } + let h = json::parse(live::lines(&t).first().copied().unwrap_or("")).unwrap_or(Json::Null); + let Some(sg) = h.get("segment").cloned() else { break }; + let (prev, sealed, index) = (sg.get("previous").and_then(Json::as_str).unwrap_or("").to_string(), sg.get("sealed").and_then(Json::as_str).unwrap_or("").to_string(), sg.get("index").and_then(Json::as_f64).unwrap_or(0.0)); + let pt = match std::fs::read_to_string(dir.join(&prev)) { Ok(x) => x, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { err_line(&format!("probbit: live verify --chain: {cur} links {prev}, which does not exist: the chain before it is not verified")); + broke(&cur, obj(vec![("ok", Json::Bool(false)), ("missing", Json::Str(prev.clone()))]), 6) } + Err(e) => fail(&format!("live verify --chain: cannot read {prev}: {e}")) }; + let pls = live::lines(&pt); let last = pls.last().copied().unwrap_or(""); + let pidx = json::parse(pls.first().copied().unwrap_or("")).ok().and_then(|h| h.get("segment").and_then(|s| s.get("index")).and_then(Json::as_f64)).unwrap_or(0.0); + let why = if persona::digest_of(last) != sealed { Some(format!("its last line is not the sealed line {cur} links")) } + else if pidx + 1.0 != index { Some(format!("its segment index is not {} (the one before {cur}'s)", index - 1.0)) } else { None }; + if let Some(why) = why { err_line(&format!("probbit: live verify --chain: {prev}: line {}: {why}", pls.len())); + broke(&prev, obj(vec![("ok", Json::Bool(false)), ("line", num(pls.len() as f64)), ("diverges", Json::Str(why))]), 1) } + cur = prev; t = pt; + } + let mut d = newest.unwrap_or(Json::Null); if let Json::Obj(v) = &mut d { v.push(("segments".into(), num(segs as f64))); } + emit(&persona::canon(&d)); return; + } let res = if args.iter().any(|a| a == "--from-checkpoint") { live::verify_from_checkpoint(&text, eng) } else { live::verify(&text, eng) }; match res { Ok(doc) => emit(&persona::canon(&doc)), @@ -853,6 +886,8 @@ fn live_cmd(args: &[String]) { drop(lock); return; } let every: u64 = arg(args, "--checkpoint-every", live::CHECKPOINT_EVERY); + let seg_every: u64 = arg(args, "--segment-every", 0); + if seg_every > 0 && opt("--strand").is_none() { fail("live: --segment-every goes with --strand NAME.strand (the segments are NAME.0001.strand, ...)") } let state_file = opt("--state"); if state_file.is_some() && opt("--seed").is_some() { fail("live: give --seed N (a new individual) or --state FILE (a stored one), not both") } let st = match &state_file { Some(f) => { let t = std::fs::read_to_string(f).unwrap_or_else(|e| bad_input("live: ", persona::perr("state", format!("cannot read {f}: {e} (make one with `probbit persona init`)")))); @@ -866,12 +901,19 @@ fn live_cmd(args: &[String]) { let strand = opt("--strand"); let lock = strand.as_ref().map(|f| live::Lock::take(f).unwrap_or_else(|e| refuse("live: ", e))); if let Some(f) = &strand { if std::path::Path::new(f).exists() && state_file.is_none() { fail(&format!("live: {f} exists: continue it with --state FILE (the state after its last line), or log to a new file")) } } + // --segment-every: the run continues the newest segment of the strand (NAME.strand, then NAME.0001.strand, ...) + let mut seg = 0u64; if seg_every > 0 { if let Some(f) = &strand { while std::path::Path::new(&live::segment_name(f, seg + 1)).exists() { seg += 1; } } } + let cur = std::cell::RefCell::new(strand.as_ref().map(|f| live::segment_name(f, seg))); + // the strand's engine (its header's; a new strand: this one's): the next segment's header names it + let strand_engine = cur.borrow().as_ref().and_then(|f| std::fs::read_to_string(f).ok()).and_then(|t| json::parse(live::lines(&t).first().copied().unwrap_or("")).ok()) + .and_then(|h| h.get("engine").and_then(Json::as_str).map(str::to_string)).unwrap_or_else(live::engine); if let Some(f) = strand.as_ref().filter(|_| !labels.is_empty()) { labels::merge_into(f, &labels).unwrap_or_else(|e| fail(&format!("live: {e}"))); } - let (mut lv, header) = match &strand { Some(f) => live::open(f, p, &doc, st, clock).unwrap_or_else(|e| bad_input("live: ", e)), None => (live::Live::start(p, &doc, st, clock, &live::engine()).0, None) }; + let (mut lv, header) = match cur.borrow().as_ref() { Some(f) => live::open(f, p, &doc, st, clock).unwrap_or_else(|e| bad_input("live: ", e)), None => (live::Live::start(p, &doc, st, clock, &live::engine()).0, None) }; + if seg_every > 0 && lv.expect.as_ref().is_some_and(|m| !m.is_canonical()) { fail("live: --segment-every: before engine 0.14.0 a checkpoint does not carry the expectation's memory, so a segment of a persona with an expectation block could not start from its sealed line") } // every append first checks the lock is still this writer's: a lock removed or taken over under it stops the run unwritten - let log = |ls: &[&str]| if let Some(f) = &strand { + let log = |ls: &[&str]| if let (Some(f), Some(c)) = (&strand, cur.borrow().as_ref()) { if !lock.as_ref().is_some_and(live::Lock::held) { refuse("live: ", json::InErr { code: "locked", path: "strand".into(), msg: format!("the writer lock {f}.lock is no longer this run's (removed or taken over): stopped before writing") }) } - live::append(f, ls).unwrap_or_else(|e| fail(&format!("live: {}", e.msg))) }; + live::append(c, ls).unwrap_or_else(|e| fail(&format!("live: {}", e.msg))) }; if let Some(h) = &header { log(&[h]); } let (mut bad, mut refused, mut warned, mut warned_credit, mut warned_needs) = (0, 0, false, false, false); // feature `turn_trace`: with PROBBIT_TURN_TRACE set, each event's phases on stderr (the parse and the append are timed here) @@ -894,10 +936,18 @@ fn live_cmd(args: &[String]) { err_line(&format!("probbit: live: events[{i}]: this persona has no needs block: `needs` is an undeclared input (listed in ignored), no amount is paid")); } lv.event(&ev, eng) }); match res { - Ok((stance, line)) => { let cp = (strand.is_some() && every > 0 && lv.n % every == 0).then(|| lv.checkpoint(&stance)); + Ok((stance, line)) => { let seal = strand.is_some() && seg_every > 0 && lv.n % seg_every == 0; + let cp = (strand.is_some() && (seal || (every > 0 && lv.n % every == 0))).then(|| lv.checkpoint(&stance)); #[cfg(feature = "turn_trace")] let t_append = std::time::Instant::now(); match &cp { Some(c) => log(&[&line, c]), None => log(&[&line]) } + // sealed: the next segment file, its header linking this one, then the sealed line itself; the run goes on there + if let (true, Some(c), Some(base)) = (seal, &cp, &strand) { + let prev = cur.borrow().clone().unwrap_or_default(); seg += 1; let next = live::segment_name(base, seg); + let pname = std::path::Path::new(&prev).file_name().map_or_else(|| prev.clone(), |f| f.to_string_lossy().into_owned()); + let h = live::segment_header(lv, &doc, &strand_engine, seg, &pname, &persona::digest_of(c)); + if std::path::Path::new(&next).exists() { fail(&format!("live: {next} exists: the strand's newest segment is not the one this run continued")) } + *cur.borrow_mut() = Some(next); log(&[&h, c]); } #[cfg(feature = "turn_trace")] if trace_on { let tr = probbit_persona::trace::last(); let us = |t: std::time::Instant| Json::Num(t.elapsed().as_micros() as f64); let mut j = tr.to_json(); if let Json::Obj(v) = &mut j { v.push(("n".into(), Json::Num(lv.n as f64))); v.push(("parse_us".into(), Json::Num(parse_us.get() as f64))); v.push(("append_us".into(), us(t_append))); } @@ -1222,7 +1272,7 @@ mod table_tests { let (v, w) = sub_flags("persona", "fuzz").unwrap(); assert!(v.contains(&"--fixture-src") && w.contains(&"--json") && w.contains(&"--pretty")); let (v, w) = flags_of("monitor"); assert!(v.contains(&"--port") && w.contains(&"--demo"), "--demo takes an optional value: parsed as a switch"); assert!(sub_flags("persona", "lnt").is_none()); - assert_eq!(sub_flags("live", "verify").unwrap(), (vec!["--actions", "--blocks"], vec!["--from-checkpoint"]), "live's own flags are not verify's"); + assert_eq!(sub_flags("live", "verify").unwrap(), (vec!["--actions", "--blocks"], vec!["--from-checkpoint", "--chain"]), "live's own flags are not verify's"); for c in COMMANDS { let (v, w) = flags_of(c.name); assert_eq!(v.len() + w.len(), c.flags().count(), "{}", c.name); } } #[test] diff --git a/probbit-cli/src/monitor.rs b/probbit-cli/src/monitor.rs index 614dfe2..43a6424 100644 --- a/probbit-cli/src/monitor.rs +++ b/probbit-cli/src/monitor.rs @@ -128,11 +128,12 @@ impl Watch { Ok(Watch { rep, meta, last: None, spark: vec![VecDeque::new(); n], bad: None }) } /// Where the replay of `lines` (the header first) starts: the last checkpoint line (index), or None for the header. RFC 0009: - /// a persona with an expectation block folds every event line into its memory, which no checkpoint carries, so its strand - /// replays from the header (every line, slower; `open_at` refuses that shortcut); any other persona starts at the last one + /// before 0.14.0 a persona with an expectation block folds every event line into its memory, which no checkpoint carries, so + /// its strand replays from the header (every line, slower; `open_at` refuses that shortcut); from 0.14.0 the checkpoint carries + /// the memory, and every strand starts at the last one fn start(lines: &[&str]) -> Option { let k = crate::live::last_checkpoint(lines, 0).filter(|k| *k >= 2)?; - (!Replay::open(lines[0]).is_ok_and(|r| r.live.p.expect.is_some())).then_some(k) + (!Replay::open(lines[0]).is_ok_and(|r| r.live.p.expect.is_some() && r.live.dev_full)).then_some(k) } /// The replay from a checkpoint line (`live::Replay::open_at`; `before` = the event line before it): the lines before it are /// not replayed, and the board starts at the checkpoint's event (its stance, the inputs `before` logs, its learned deltas) @@ -1167,14 +1168,22 @@ mod tests { let _ = std::fs::remove_file(&f); } - /// RFC 0009: a persona with an expectation block replays a strand with checkpoint lines from its header (the checkpoint does - /// not carry the expectation's memory): `--once` and the first poll of `--follow` / `--serve` draw the latest event with no - /// checkpoint start, and lines appended after it replay from the state in memory, one by one + /// RFC 0009: before 0.14.0 a persona with an expectation block replays a strand with checkpoint lines from its header (the + /// checkpoint does not carry the expectation's memory): `--once` and the first poll of `--follow` / `--serve` draw the latest + /// event with no checkpoint start, and lines appended after it replay from the state in memory, one by one. From 0.14.0 the + /// checkpoint carries the memory and the same life starts at its last checkpoint. #[test] fn an_expectation_persona_replays_from_the_header() { let (p, doc) = persona::load(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/monitor/tutor-expect.yaml")).unwrap(); assert!(p.expect.is_some(), "the fixture has an expectation block"); - let (mut lv, header) = Live::start(p.clone(), &doc, persona::init(&p, Some(2), true, &run), Clock::Fixed, "probbit test"); + { let (mut lv, header) = Live::start(p.clone(), &doc, persona::init(&p, Some(2), true, &run), Clock::Fixed, "probbit 0.14.0"); let mut text = format!("{header}\n"); + for t in 0..17u32 { let (s, line) = lv.event(&json::parse(&format!(r#"{{"praise":{},"elapsed_hours":1}}"#, t % 3 == 0)).unwrap(), &run).unwrap(); + text += &line; text.push('\n'); if lv.n % 5 == 0 { text += &lv.checkpoint(&s); text.push('\n'); } } + let lines = live::lines(&text); let k = live::last_checkpoint(&lines, 0).unwrap(); assert_eq!(Watch::start(&lines), Some(k), "0.14.0: the checkpoint start"); + let mut w = Watch::open_at(lines[0], lines[k - 1], lines[k], k + 1).unwrap(); for l in &lines[k + 1..] { w.feed(l, &run); } + let mut full = Watch::open(lines[0]).unwrap(); for l in &lines[1..] { full.feed(l, &run); } + assert!(w.bad.is_none(), "{:?}", w.bad); assert_eq!(persona::canon(&w.last.as_ref().unwrap().doc), persona::canon(&full.last.as_ref().unwrap().doc)); } + let (mut lv, header) = Live::start(p.clone(), &doc, persona::init(&p, Some(2), true, &run), Clock::Fixed, "probbit 0.13.0"); let mut text = format!("{header}\n"); let add = |lv: &mut Live, text: &mut String, t: u32| { let ev = json::parse(&format!(r#"{{"praise":{},"elapsed_hours":1}}"#, t % 3 == 0)).unwrap(); let (s, line) = lv.event(&ev, &run).unwrap(); *text += &line; text.push('\n'); if lv.n % 5 == 0 { *text += &lv.checkpoint(&s); text.push('\n'); } }; diff --git a/probbit-cli/tests/live.rs b/probbit-cli/tests/live.rs index a3b2291..f2ff55f 100644 --- a/probbit-cli/tests/live.rs +++ b/probbit-cli/tests/live.rs @@ -682,3 +682,44 @@ fn a_strand_with_checkpoints_resumes_with_and_without_an_expectation_block() { clean(&[&all, &head, &last, &st1, &one, &st2, &two]); } } + +/// Segments (docs/persona.md §5.7): a life logged with `--segment-every 50` over two runs (70 events, then 50) is three files whose +/// lines, each segment's header and repeated sealed line set aside, are the life logged to one file with a checkpoint after every +/// 50th event; the stances and the state are the same; each segment verifies on its own (from its header's link) and +/// `verify --chain` walks back through all three; a previous segment whose last line is not the sealed line exits 1 naming that +/// file, a missing one exits 6 naming it; before 0.14.0 a persona with an expectation block cannot be segmented +#[test] +fn a_segmented_life_equals_one_file_and_verify_chain_walks_back() { + let evs: Vec = (0..120).map(|t| match t % 3 { 0 => r#"{"elapsed_hours":1,"praise":true}"#, 1 => r#"{"elapsed_hours":0.5,"error":true}"#, _ => r#"{"elapsed_hours":2}"# }.to_string()).collect(); + let all = events_file("seg-all.jsonl", &evs); let (a, b) = (events_file("seg-a.jsonl", &evs[..70]), events_file("seg-b.jsonl", &evs[70..])); + let st1 = init(TUTOR, 3, "seg-one.json"); let one = tmp("seg-one.strand"); clean(&[&one]); + let (c, out1, e) = live(TUTOR, &st1, &all, &one, "50"); assert_eq!(c, 0, "{e}"); + let dir = tmp("seg-dir"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); + let base = format!("{dir}/life.strand"); let seg = |k: usize| if k == 0 { base.clone() } else { format!("{dir}/life.{k:04}.strand") }; + let st2 = init(TUTOR, 3, "seg-two.json"); + let run = |ev: &str| probbit(&["live", TUTOR, "--state", &st2, "--clock", "fixed", "--events", ev, "--strand", &base, "--checkpoint-every", "50", "--segment-every", "50"]); + let (c, o1, e) = run(&a); assert_eq!(c, 0, "{e}"); let (c, o2, e) = run(&b); assert_eq!(c, 0, "{e}"); + assert_eq!(format!("{o1}{o2}"), out1, "the stances"); assert_eq!(read(&st2), read(&st1), "the state"); + assert!(std::path::Path::new(&seg(2)).exists() && !std::path::Path::new(&seg(3)).exists(), "three segments"); + let texts: Vec = (0..3).map(|k| read(&seg(k))).collect(); + let mut joined: Vec<&str> = texts[0].lines().collect(); for t in &texts[1..] { joined.extend(t.lines().skip(2)); } + assert_eq!(joined, read(&one).lines().collect::>(), "the segments are the one-file strand"); + for k in 1..3 { let h = parse(texts[k].lines().next().unwrap()); let sg = s(&h, &["segment"]); + assert_eq!(s(sg, &["index"]), &Json::Num(k as f64)); assert_eq!(s(sg, &["previous"]).as_str(), Some(if k == 1 { "life.strand".to_string() } else { format!("life.{:04}.strand", k - 1) }.as_str())); + assert_eq!(texts[k].lines().nth(1), texts[k - 1].lines().last(), "segment {k} repeats the sealed line"); } + let (_, vone, _) = probbit(&["live", "verify", &one]); let vone = parse(&vone); + for k in 0..3 { let (c, v, e) = probbit(&["live", "verify", &seg(k)]); assert_eq!(c, 0, "segment {k}: {e}"); if k > 0 { assert!(v.contains(&format!(r#""segment":{k}"#)) && v.contains("trusted_link"), "{v}"); } } + let (c, v, e) = probbit(&["live", "verify", &seg(2), "--chain"]); assert_eq!(c, 0, "{e}"); let v = parse(&v); + assert_eq!(s(&v, &["segments"]), &Json::Num(3.0)); assert_eq!(s(&v, &["final_state"]), s(&vone, &["final_state"])); assert_eq!(s(&v, &["last_line"]), s(&vone, &["last_line"])); + // the link broken: the first segment's sealed line edited (kept canonical): the chain breaks there, named + std::fs::write(seg(1), texts[1].replacen(r#"{"checkpoint":100,"#, r#"{"checkpoint":100,"x":1,"#, 1)).unwrap(); + let (c, v, _) = probbit(&["live", "verify", &seg(2), "--chain"]); assert_eq!(c, 1, "{v}"); assert!(v.contains(r#""segment_file":"life.0001.strand""#), "{v}"); + std::fs::remove_file(seg(1)).unwrap(); + let (c, v, _) = probbit(&["live", "verify", &seg(2), "--chain"]); assert_eq!(c, 6, "{v}"); + assert!(v.contains(r#""missing":"life.0001.strand""#) && v.contains(r#""segment_file":"life.0002.strand""#), "{v}"); + // before 0.14.0 (this engine's strands) an expectation persona is not segmented + let (st3, f3) = (init("probbit-cli/tests/fixtures/monitor/tutor-expect.yaml", 2, "seg-x.json"), format!("{dir}/x.strand")); + let (c, _, e) = probbit(&["live", "probbit-cli/tests/fixtures/monitor/tutor-expect.yaml", "--state", &st3, "--clock", "fixed", "--events", &a, "--strand", &f3, "--segment-every", "50"]); + assert_eq!(c, 2, "{e}"); assert!(e.contains("--segment-every"), "{e}"); + let _ = std::fs::remove_dir_all(&dir); clean(&[&all, &a, &b, &st1, &one, &st2, &st3]); +} diff --git a/probbit-persona/src/persona.rs b/probbit-persona/src/persona.rs index 685b939..3f824cf 100644 --- a/probbit-persona/src/persona.rs +++ b/probbit-persona/src/persona.rs @@ -3917,13 +3917,53 @@ fn traj_spec(x: &Json) -> R { /// latest event that had it (that combo's member), whether a non-time combo has been seen, and the learned normal of /// ln(1 + pred). A fold over the logged events (their inputs and deviation receipts), so it lives with a strand's replay and /// never in the state: the state digest is the same with the block on or off. +/// +/// The canonical fold (`canonical`, strands of engine 0.14.0 on): after every step each count, the mean and the variance are at +/// 6 decimals (a count at 0 is dropped) and at most `max_combos - 1` named combos are kept, the one last in the mixture's order +/// moving its count to `rest` (added to the empty member's weight, where the mixture puts every combo past the kept ones). The +/// memory is then a small exact object a checkpoint line carries (`to_json`, `read`), the same bytes on every libm, and a fold +/// restarted from it equals the fold from the strand's header. #[derive(Clone, Debug, Default)] -pub struct ExpectMem { combos: Vec<(String, f64, Json)>, warm: bool, mean: f64, var: f64, n: u64 } +pub struct ExpectMem { combos: Vec<(String, f64, Json)>, warm: bool, mean: f64, var: f64, n: u64, rest: f64, canon: bool } const DEV_PRIOR_VAR: f64 = 0.04; const DEV_VAR_FLOOR: f64 = 1e-4; /// z at or above this registers the deviation (the RFC 0001 sense threshold) pub const DEV_Z: f64 = 1.5; -impl ExpectMem { pub fn new() -> ExpectMem { ExpectMem { var: DEV_PRIOR_VAR, ..Default::default() } } } +impl ExpectMem { + pub fn new() -> ExpectMem { ExpectMem { var: DEV_PRIOR_VAR, ..Default::default() } } + /// The empty memory of the canonical fold (strands of engine 0.14.0 on) + pub fn canonical() -> ExpectMem { ExpectMem { canon: true, ..ExpectMem::new() } } + /// Whether this is the canonical fold (its memory goes on checkpoint lines) + pub fn is_canonical(&self) -> bool { self.canon } + /// The memory as a checkpoint line's `expect`: {combos: [[count, inputs], ...] in key order, mean, n, rest, var, warm}; the key of + /// a combo is a function of its inputs (`combo_key`), so it is not written + pub fn to_json(&self) -> Json { + let mut c: Vec<&(String, f64, Json)> = self.combos.iter().collect(); c.sort_by(|a, b| a.0.cmp(&b.0)); + Json::Obj(vec![("combos".into(), Json::Arr(c.iter().map(|x| Json::Arr(vec![Json::Num(x.1), x.2.clone()])).collect())), ("mean".into(), Json::Num(self.mean)), + ("n".into(), Json::Num(self.n as f64)), ("rest".into(), Json::Num(self.rest)), ("var".into(), Json::Num(self.var)), ("warm".into(), Json::Bool(self.warm))]) + } + /// A checkpoint line's `expect` for persona `p` -> the canonical memory; Err(what is wrong). It must be what the canonical fold + /// can hold: combos in key order, each count > 0 at 6 decimals, distinct keys, at most `max_combos - 1` named ones + pub fn read(p: &Persona, j: &Json) -> Result { + let ex = p.expect.as_ref().ok_or("the persona has no expectation block")?; + let kv = j.as_obj().filter(|kv| kv.len() == 6).ok_or("an object {combos, mean, n, rest, var, warm}")?; + let f = |k: &str| kv.iter().find(|(x, _)| x == k).map(|(_, v)| v).ok_or(format!("no {k}")); + let r6n = |k: &str| -> Result { f(k)?.as_f64().filter(|x| x.is_finite() && *x >= 0.0 && r6(*x) == *x).ok_or(format!("{k}: a number >= 0 at 6 decimals")) }; + let mean = f("mean")?.as_f64().filter(|x| x.is_finite() && r6(*x) == *x).ok_or("mean: a number at 6 decimals")?; + let (var, rest) = (r6n("var")?, r6n("rest")?); + let n = f("n")?.as_f64().filter(|x| *x >= 0.0 && x.fract() == 0.0 && *x <= 9_007_199_254_740_992.0).ok_or("n: a whole number >= 0")? as u64; + let Json::Bool(warm) = f("warm")? else { return Err("warm: true or false".into()) }; + let mut combos: Vec<(String, f64, Json)> = vec![]; + for c in f("combos")?.as_arr().ok_or("combos: a list of [count, inputs]")? { + let (cnt, rep) = match c.as_arr() { Some([Json::Num(x), r @ Json::Obj(_)]) => (*x, r), _ => return Err("combos: each [count, inputs]".into()) }; + if !(cnt.is_finite() && cnt > 0.0 && r6(cnt) == cnt) { return Err("combos: each count > 0 at 6 decimals".into()); } + let key = combo_key(p, rep.as_obj().unwrap_or(&[])); + if combos.last().is_some_and(|l| l.0 >= key) { return Err("combos: in key order, each key once".into()); } + combos.push((key, cnt, rep.clone())); } + if combos.iter().filter(|c| c.0 != "{}").count() > ex.max_combos - 1 { return Err(format!("combos: at most {} named", ex.max_combos - 1)); } + Ok(ExpectMem { combos, warm: *warm, mean, var, n, rest, canon: true }) + } +} /// One event's deviation from the engine's own expectation (nats): `s_self` = sum over the non-twin variables of KL(A_i || E_i), /// `pred` = the same against the mixture Pi_i, `input` = -ln of the mixture weight of the event's combo (its own member's, /// else the empty member's), `cold` until the first non-time combo; `member`: the combo has its own member; `z` against the @@ -3950,6 +3990,8 @@ pub fn expect_weights(ex: &Expect, mem: &ExpectMem) -> Vec<(String, f64)> { let keep = ex.max_combos - 1; let mut empty = 1.0 + mem.combos.iter().find(|x| x.0 == "{}").map_or(0.0, |x| x.1); for x in c.iter().skip(keep) { empty += x.1; } + // the canonical fold's evicted counts (0 in the fold before 0.14.0: the sum is unchanged) + if mem.canon { empty += mem.rest; } c.truncate(keep); let tot = c.iter().fold(empty, |a, x| a + x.1); let mut out = vec![("{}".to_string(), empty / tot)]; out.extend(c.into_iter().map(|(k, n)| (k, n / tot))); out @@ -4010,11 +4052,29 @@ pub fn expect_step(p: &Persona, mem: &mut ExpectMem, raw: &Json, logged_pred: Op let Some(ex) = p.expect.as_ref() else { return }; let Some(kv) = raw.as_obj() else { return }; let d = 0.5f64.powf(1.0 / ex.half_life); let key = combo_key(p, kv); let rep = Json::Obj(kv.iter().filter(|(k, _)| k != "elapsed_hours").cloned().collect()); + // the canonical fold keeps a member's inputs with their keys sorted at every level, as a checkpoint line carries them + let rep = if mem.canon { key_sorted(&rep) } else { rep }; for c in &mut mem.combos { c.1 *= d; } match mem.combos.iter_mut().find(|c| c.0 == key) { Some(c) => { c.1 += 1.0; c.2 = rep; } None => mem.combos.push((key.clone(), 1.0, rep)) } if key != "{}" { mem.warm = true; } if let Some(x) = logged_pred { let (x, a) = ((1.0 + x).ln(), 1.0 - d); let dx = x - mem.mean; mem.mean += a * dx; mem.var = (1.0 - a) * (mem.var + a * dx * dx); mem.n += 1; } + if mem.canon { + mem.rest = r6(mem.rest * d); + for c in &mut mem.combos { c.1 = r6(c.1); } + mem.combos.retain(|c| c.1 > 0.0); + // past max_combos - 1 named combos: the last in the mixture's order (count desc, key asc) moves its count to `rest` + while mem.combos.iter().filter(|c| c.0 != "{}").count() > ex.max_combos - 1 { + let Some(i) = (0..mem.combos.len()).filter(|&i| mem.combos[i].0 != "{}").min_by(|&a, &b| { let (x, y) = (&mem.combos[a], &mem.combos[b]); + x.1.partial_cmp(&y.1).unwrap_or(std::cmp::Ordering::Equal).then(y.0.cmp(&x.0)) }) else { break }; + let c = mem.combos.remove(i); mem.rest = r6(mem.rest + c.1); } + mem.mean = r6(mem.mean); mem.var = r6(mem.var); + } +} +/// `j` with every object's keys sorted (by code point) at every level: the value canonical JSON writes, as a value +fn key_sorted(j: &Json) -> Json { + match j { Json::Obj(v) => { let mut o: Vec<(String, Json)> = v.iter().map(|(k, x)| (k.clone(), key_sorted(x))).collect(); o.sort_by(|a, b| a.0.cmp(&b.0)); Json::Obj(o) } + Json::Arr(v) => Json::Arr(v.iter().map(key_sorted).collect()), x => x.clone() } } impl Deviation { /// The stance's `deviation`: every number at 6 decimals (the canonical number rule), so a checkpoint line carries the same diff --git a/probbit-persona/src/strand.rs b/probbit-persona/src/strand.rs index 83016cd..8d08a8f 100644 --- a/probbit-persona/src/strand.rs +++ b/probbit-persona/src/strand.rs @@ -102,8 +102,8 @@ impl Live { #[inline(never)] pub fn start_to(p: Box, doc: &Json, st: Box, clock: Clock, engine: &str, chunk: usize, out: &mut dyn FnMut(&[u8])) -> Box { let prev = write_header(&p, doc, &st, engine, chunk, out); - let expect = p.expect.as_ref().map(|_| persona::ExpectMem::new()); let dev_full = engine_version(engine).is_some_and(|v| v < DEV_R6_SINCE); + let expect = p.expect.as_ref().map(|_| new_mem(dev_full)); Live::boxed(p, st, clock, prev, 0, expect, dev_full) } /// The individual on the heap from its parts (one place builds a `Live`: `start_to` and `resume_boxed`), written in place: @@ -181,11 +181,15 @@ impl Live { self.st = self.st.zero_credit(&self.p); if what == "erase" { self.st = self.st.erased(&self.p); } self.status = to; self.controls += 1; self.prev = persona::digest_of(&line); Ok(line) } - /// A checkpoint line after event n: the event count, the whole state after it and that event's stance document, chained -> - /// the line. A replay can start there (the state) and a board can be drawn there (the stance) without the events before. + /// A checkpoint line after event n: the event count, the whole state after it and that event's stance document (from 0.14.0, + /// with an expectation block, also the expectation's memory: `expect`), chained -> the line. A replay can start there (the + /// state) and a board can be drawn there (the stance) without the events before. pub fn checkpoint(&mut self, stance: &Json) -> String { - let line = persona::canon(&Json::Obj(vec![("checkpoint".into(), Json::Num(self.n as f64)), ("prev".into(), Json::Str(self.prev.clone())), ("stance".into(), stance.clone()), - ("state".into(), self.st.to_json(&self.p))])); + let mut kv = vec![("checkpoint".into(), Json::Num(self.n as f64)), ("prev".into(), Json::Str(self.prev.clone())), ("stance".into(), stance.clone()), + ("state".into(), self.st.to_json(&self.p))]; + // from 0.14.0 the expectation's memory (the canonical fold) rides on the line, so a replay and a resume can start here + if let Some(m) = self.expect.as_ref().filter(|m| m.is_canonical()) { kv.push(("expect".into(), m.to_json())); } + let line = persona::canon(&Json::Obj(kv)); self.checkpoints += 1; self.prev = persona::digest_of(&line); line } /// The sha256 of the strand's last line (the header's before any event) @@ -210,6 +214,11 @@ impl persona::Sink for Pieces<'_> { /// at most `chunk` bytes (a 0 is taken as 1) -> its sha256 (`sha256:` + hex), the first line's `prev`. Neither the line nor a /// copy of the document is held whole: the buffer is one piece. pub fn write_header(p: &Persona, doc: &Json, st: &State, engine: &str, chunk: usize, out: &mut dyn FnMut(&[u8])) -> String { + write_header_seg(p, doc, st, engine, None, chunk, out) +} +/// `write_header` of a segment (docs/persona.md §5.7, "Segments"): the header with one more key at its end, `segment` = {index, +/// previous, sealed} (canonical JSON); None writes `write_header`'s bytes +pub fn write_header_seg(p: &Persona, doc: &Json, st: &State, engine: &str, segment: Option<&Json>, chunk: usize, out: &mut dyn FnMut(&[u8])) -> String { use persona::Sink; let cap = chunk.max(1); let mut w = Pieces { buf: Vec::with_capacity(cap), cap, out, h: persona::Sha256::new() }; @@ -221,6 +230,7 @@ pub fn write_header(p: &Persona, doc: &Json, st: &State, engine: &str, chunk: us w.push_str(",\"seed\":"); persona::write_to(&Json::Num(st.seed as f64), false, &mut w); w.push_str(",\"state\":"); persona::write_to(&st.to_json(p), true, &mut w); w.push_str(",\"document\":"); persona::write_to(doc, false, &mut w); + if let Some(sg) = segment { w.push_str(",\"segment\":"); persona::write_to(sg, true, &mut w); } w.push('}'); if !w.buf.is_empty() { (w.out)(&w.buf); } format!("sha256:{}", persona::hex(&w.h.finish())) @@ -244,14 +254,40 @@ fn rest_line(prev: &str, rest: Json, state: &str) -> String { /// What kind of strand line `j` is pub enum Kind { Event, Control, Checkpoint, Rest } pub fn kind(j: &Json) -> Kind { if j.get("control").is_some() { Kind::Control } else if j.get("checkpoint").is_some() { Kind::Checkpoint } else if j.get("rest").is_some() { Kind::Rest } else { Kind::Event } } -/// The header's keys, as this engine writes them +/// The header's keys, as this engine writes them (a segment's header has one more at its end: `segment`) const HEADER: [&str; 6] = ["probbit_strand", "engine", "persona", "seed", "state", "document"]; +/// The divergence of a segment whose second line is not the line its header links +pub const SEALED: &str = "the line after a segment's header is not its sealed line (its sha256 is not the header's `sealed`)"; +/// A header's `segment`: exactly {index: a whole number >= 1, previous: a file name (1-255 characters, no `/` or `\\`), sealed: a +/// sha256} +fn check_segment(sg: &Json) -> Result<(), String> { + let kv = sg.as_obj().filter(|kv| kv.len() == 3).ok_or("an object {index, previous, sealed}")?; + let f = |k: &str| kv.iter().find(|(x, _)| x == k).map(|(_, v)| v); + f("index").and_then(Json::as_f64).filter(|x| *x >= 1.0 && x.fract() == 0.0 && *x <= 9_007_199_254_740_992.0).ok_or("index: a whole number >= 1")?; + f("previous").and_then(Json::as_str).filter(|s| !s.is_empty() && s.len() <= 255 && !s.contains(['/', '\\'])).ok_or("previous: the previous segment's file name")?; + f("sealed").and_then(Json::as_str).filter(|s| s.len() == 71 && s.starts_with("sha256:") && s[7..].bytes().all(|c| c.is_ascii_hexdigit())).ok_or("sealed: a sha256")?; + Ok(()) +} +/// The file name of segment `k` of the strand `base` (§5.7): `NAME.strand` for 0, `NAME.000k.strand` after it (four digits at least); +/// a base without `.strand` gets `.000k` appended +pub fn segment_name(base: &str, k: u64) -> String { + if k == 0 { return base.to_string(); } + match base.strip_suffix(".strand") { Some(stem) => format!("{stem}.{k:04}.strand"), None => format!("{base}.{k:04}") } +} +/// The header of segment `index` that continues `lv` after its sealed line (whose sha256 is `sealed`; `previous` is the file name +/// of the segment it seals), for the persona document `doc` and the strand's `engine` -> the header line. The new file is this +/// header, then the sealed line itself (byte for byte), then the events: the chain goes on across the files unchanged. +pub fn segment_header(lv: &Live, doc: &Json, engine: &str, index: u64, previous: &str, sealed: &str) -> String { + let sg = Json::Obj(vec![("index".into(), Json::Num(index as f64)), ("previous".into(), Json::Str(previous.into())), ("sealed".into(), Json::Str(sealed.into()))]); + let mut b = Vec::new(); write_header_seg(&lv.p, doc, &lv.st, engine, Some(&sg), HEADER_CHUNK, &mut |c: &[u8]| b.extend_from_slice(c)); + String::from_utf8(b).unwrap_or_default() +} /// The earliest key of a line after the header that its kind does not have (the line's keys in canonical order): a line kind /// this engine does not know (a line without `control` or `checkpoint` is read as an event line, so `{"prev": ..., "nap": ...}` /// gives `nap`) or a field it does not know on a known one fn unknown_key(j: &Json) -> Option<&str> { let known: &[&str] = match kind(j) { Kind::Event => &["deviation", "glyph", "inputs", "n", "prev", "stance", "state"], Kind::Control => &["at", "by", "control", "prev", "reason"], - Kind::Checkpoint => &["checkpoint", "prev", "stance", "state"], Kind::Rest => &["prev", "rest", "state"] }; + Kind::Checkpoint => &["checkpoint", "expect", "prev", "stance", "state"], Kind::Rest => &["prev", "rest", "state"] }; j.as_obj()?.iter().map(|(k, _)| k.as_str()).find(|k| !known.contains(k)) } /// The start of a replay's `why` that is not a divergence: the line has a field or a line kind this engine does not know (a @@ -304,12 +340,16 @@ impl Replay { pub fn open(head: &str) -> Result { let h = json::parse(head).map_err(|e| (1, format!("the header is not JSON: {}", e.msg)))?; if h.get("probbit_strand").and_then(Json::as_f64) != Some(FORMAT) { return Err((1, "not a probbit strand (format 1)".into())); } - if let Some((k, _)) = h.as_obj().and_then(|kv| kv.iter().find(|(k, _)| !HEADER.contains(&k.as_str()))) { return Err((1, unsupported(k))); } + if let Some((k, _)) = h.as_obj().and_then(|kv| kv.iter().find(|(k, _)| !HEADER.contains(&k.as_str()) && k != "segment")) { return Err((1, unsupported(k))); } + if let Some(sg) = h.get("segment") { check_segment(sg).map_err(|e| (1, format!("the header's segment: {e}")))?; } let doc = h.get("document").cloned().ok_or((1, "the header has no persona document".to_string()))?; let p = persona::build(&doc).map_err(|e| (1, read_err(e, "the persona document: ")))?; if h.get("persona").and_then(|x| x.get("digest")).and_then(Json::as_str) != Some(p.digest.as_str()) { return Err((1, "the persona digest does not match the document".into())); } let st0 = State::read(&p, h.get("state").unwrap_or(&Json::Null)).map_err(|e| (1, read_err(e, "the initial state: ")))?; let (live, header) = Live::start(p, &doc, st0, Clock::Fixed, h.get("engine").and_then(Json::as_str).unwrap_or("")); + let header = match h.get("segment") { None => header, Some(sg) => { let mut b = Vec::new(); + write_header_seg(&live.p, &doc, &live.st, h.get("engine").and_then(Json::as_str).unwrap_or(""), Some(sg), HEADER_CHUNK, &mut |c: &[u8]| b.extend_from_slice(c)); + String::from_utf8(b).unwrap_or_default() } }; if header != head { return Err((1, "the header is not as written".into())); } let norms = Normals::start(&live.p, &live.st); let at_cap = vec![0.0; persona::deficits(&live.p, &live.st).len()]; let seen = (0.0, vec![0.0; persona::gate_gains(&live.p, &live.st).len()]); let viab = viab_start(&live); @@ -322,12 +362,35 @@ impl Replay { pub fn open_at(head: &str, before: &str, cp: &str, at: usize) -> Result { let mut r = Replay::open(head)?; // RFC 0009: the expectation's memory is a fold of every event line, so a strand with the block replays from its header - if r.live.p.expect.is_some() { return Err((at, "the persona's expectation block reads every event before this checkpoint: verify this strand from its header".into())); } - let (n, stance, st) = check_checkpoint(&r.live.p, before, cp, at)?; - r.live.st = *st; r.live.n = n; r.live.prev = persona::digest_of(cp); r.live.checkpoints = 1; r.line = at; r.from = Some(n); r.last = Some(stance); r.checkpoint_ready = false; - r.norms = Normals::start(&r.live.p, &r.live.st); r.at_cap = vec![0.0; r.at_cap.len()]; r.seen = (0.0, vec![0.0; r.seen.1.len()]); r.viab = viab_start(&r.live); + // RFC 0009: before 0.14.0 the expectation's memory is a fold of every event line and no checkpoint carries it, so such a + // strand replays from its header; from 0.14.0 the checkpoint carries it + let canon = !r.live.dev_full; + if r.live.p.expect.is_some() && !canon { return Err((at, "the persona's expectation block reads every event before this checkpoint: verify this strand from its header".into())); } + let (n, stance, st, mem) = check_checkpoint(&r.live.p, canon, Some(before), cp, at)?; + r.start_at(n, stance, st, mem, cp, at); r.live.checkpoints = 1; Ok(r) } + /// The replay restarted at the checkpoint line `cp` (1-based line `at`): its count, stance, state and memory + fn start_at(&mut self, n: u64, stance: Json, st: Box, mem: Option, cp: &str, at: usize) { + let r = self; + r.live.st = *st; r.live.n = n; if mem.is_some() { r.live.expect = mem; } r.live.prev = persona::digest_of(cp); r.line = at; r.from = Some(n); r.last = Some(stance); r.checkpoint_ready = false; + r.norms = Normals::start(&r.live.p, &r.live.st); r.at_cap = vec![0.0; r.at_cap.len()]; r.seen = (0.0, vec![0.0; r.seen.1.len()]); r.viab = viab_start(&r.live); + } + /// The replay of a strand's `lines` from their start -> (the replay, the index of the first line still to step): the header, or + /// for a segment (a header with `segment`) the header and its sealed line (line 2), taken on the header's link: its sha256 is + /// the header's `sealed`, it is a valid checkpoint line and its state is the header's; the replay goes on from it + pub fn open_lines(lines: &[&str]) -> Result<(Replay, usize), (usize, String)> { + let mut r = Replay::open(lines.first().copied().unwrap_or(""))?; + let Some(sg) = r.header.get("segment").cloned() else { return Ok((r, 1)) }; + let canon = !r.live.dev_full; + if r.live.p.expect.is_some() && !canon { return Err((1, "a segment of a persona with an expectation block needs a strand of engine 0.14.0 or later".into())); } + let cp = lines.get(1).copied().unwrap_or(""); + if Some(persona::digest_of(cp).as_str()) != sg.get("sealed").and_then(Json::as_str) { return Err((2, SEALED.into())); } + let (n, stance, st, mem) = check_checkpoint(&r.live.p, canon, None, cp, 2)?; + if st.digest != r.live.st.digest { return Err((2, "the sealed line's state is not the segment header's".into())); } + r.start_at(n, stance, st, mem, cp, 2); + Ok((r, 2)) + } /// The stance document of the last event replayed (or of the checkpoint the replay started from) pub fn last_stance(&self) -> Option<&Json> { self.last.as_ref() } /// The senses' normals over the replay so far (§2.11) @@ -416,6 +479,8 @@ impl Replay { if let Some((sp, pend)) = persona::rest_pressure(&live.st) { v.push(("pressure".into(), Json::Obj(vec![("s".into(), Json::Num(sp)), ("pending".into(), Json::Bool(pend))]))); } if let Some((l, _)) = persona::load_level(&live.p, &live.st) { v.push(("load".into(), Json::Obj(vec![("l".into(), Json::Num(l))]))); } if let Some(n) = self.from { v.push(("from_checkpoint".into(), Json::Num(n as f64))); } + // a segment (§5.7): its index and the digest of the sealed line its replay started from, taken on the link + if let Some(sg) = self.header.get("segment") { v.push(("segment".into(), sg.get("index").cloned().unwrap_or(Json::Null))); v.push(("trusted_link".into(), sg.get("sealed").cloned().unwrap_or(Json::Null))); } // a senses block (§2.11): per sense the normal now and the largest move of its mean within 50 events let nm = persona::normals(&live.p, &live.st); if !nm.is_empty() { v.push(("normals".into(), Json::Obj(nm.iter().enumerate().map(|(k, (id, m, n, _))| (id.clone(), Json::Obj(vec![("mean".into(), Json::Num(*m)), @@ -445,8 +510,8 @@ pub type Blocks = Vec<(u64, String)>; pub fn verify_blocks(text: &str, eng: Engine) -> Result<(Json, Blocks), (usize, String)> { let lines = lines(text); let replay = || -> Result<(Json, Blocks), (usize, String)> { - let mut r = Replay::open(lines.first().copied().unwrap_or(""))?; let mut out = vec![]; - for l in lines.iter().skip(1) { if let Some(doc) = r.step(l, eng)? { out.push((r.live.n, persona::block(&r.live.p, &doc))); } } + let (mut r, k) = Replay::open_lines(&lines)?; let mut out = vec![]; + for l in lines.iter().skip(k) { if let Some(doc) = r.step(l, eng)? { out.push((r.live.n, persona::block(&r.live.p, &doc))); } } Ok((r.summary(), out)) }; replay().map_err(|e| chain_first(&lines, e)) } @@ -465,8 +530,8 @@ pub fn lines(text: &str) -> Vec<&str> { pub fn verify(text: &str, eng: Engine) -> Result { let lines = lines(text); let replay = || -> Result { - let mut r = Replay::open(lines.first().copied().unwrap_or(""))?; - for l in lines.iter().skip(1) { r.step(l, eng)?; } + let (mut r, k) = Replay::open_lines(&lines)?; + for l in lines.iter().skip(k) { r.step(l, eng)?; } r.end()?; Ok(r.summary()) }; replay().map_err(|e| chain_first(&lines, e)) } @@ -493,7 +558,8 @@ pub fn last_checkpoint(lines: &[&str], after: usize) -> Option { /// `verify --from-checkpoint`: replay from the last checkpoint line (the header read as always), not from the header pub fn verify_from_checkpoint(text: &str, eng: Engine) -> Result { let lines = lines(text); - let Some(k) = last_checkpoint(&lines, 0) else { return verify(text, eng) }; + // a segment's sealed line (line 2) is where its own replay starts anyway + let Some(k) = last_checkpoint(&lines, 0).filter(|&k| k >= 2) else { return verify(text, eng) }; let replay = || -> Result { let mut r = Replay::open_at(lines[0], lines[k - 1], lines[k], k + 1)?; for l in lines.iter().skip(k + 1) { r.step(l, eng)?; } @@ -504,8 +570,8 @@ pub fn verify_from_checkpoint(text: &str, eng: Engine) -> Result Result<(Json, Vec<(u64, Json)>, Persona), (usize, String)> { let lines = lines(text); let replay = || -> Result<(Json, Vec<(u64, Json)>, Persona), (usize, String)> { - let mut r = Replay::open(lines.first().copied().unwrap_or(""))?; let mut kept = vec![]; - for l in lines.iter().skip(1) { if let Some(doc) = r.step(l, eng)? { + let (mut r, k) = Replay::open_lines(&lines)?; let mut kept = vec![]; + for l in lines.iter().skip(k) { if let Some(doc) = r.step(l, eng)? { if let Some(t) = doc.get("turn").and_then(Json::as_f64).map(|x| x as u64).filter(|t| keep.contains(t)) { kept.push((t, doc)); } } } r.end()?; Ok((r.summary(), kept, r.live.p.clone())) }; replay().map_err(|e| chain_first(&lines, e)) @@ -574,13 +640,14 @@ pub fn check_chain(text: &str) -> Result { check_chain_head(text) fn open_head(head: &str) -> Result<(Box, Json), (usize, String)> { let h = json::parse(head).map_err(|e| (1, format!("the header is not JSON: {}", e.msg)))?; if h.get("probbit_strand").and_then(Json::as_f64) != Some(FORMAT) { return Err((1, "not a probbit strand (format 1)".into())); } - if let Some((k, _)) = h.as_obj().and_then(|kv| kv.iter().find(|(k, _)| !HEADER.contains(&k.as_str()))) { return Err((1, unsupported(k))); } + if let Some((k, _)) = h.as_obj().and_then(|kv| kv.iter().find(|(k, _)| !HEADER.contains(&k.as_str()) && k != "segment")) { return Err((1, unsupported(k))); } + if let Some(sg) = h.get("segment") { check_segment(sg).map_err(|e| (1, format!("the header's segment: {e}")))?; } let doc = h.get("document").ok_or((1, "the header has no persona document".to_string()))?; let p = persona::build_boxed(doc).map_err(|e| (1, read_err(e, "the persona document: ")))?; if h.get("persona").and_then(|x| x.get("digest")).and_then(Json::as_str) != Some(p.digest.as_str()) { return Err((1, "the persona digest does not match the document".into())); } let st0 = read_boxed(&p, h.get("state").unwrap_or(&Json::Null)).map_err(|e| (1, read_err(e, "the initial state: ")))?; let (hb, mut at, mut same) = (head.as_bytes(), 0usize, true); - write_header(&p, doc, &st0, h.get("engine").and_then(Json::as_str).unwrap_or(""), HEADER_CHUNK, &mut |c: &[u8]| { same = same && hb.get(at..at + c.len()) == Some(c); at += c.len(); }); + write_header_seg(&p, doc, &st0, h.get("engine").and_then(Json::as_str).unwrap_or(""), h.get("segment"), HEADER_CHUNK, &mut |c: &[u8]| { same = same && hb.get(at..at + c.len()) == Some(c); at += c.len(); }); if !same || at != hb.len() { return Err((1, "the header is not as written".into())); } Ok((p, h)) } @@ -589,27 +656,33 @@ fn open_head(head: &str) -> Result<(Box, Json), (usize, String)> { /// the stance and the state (read as any state is) that event logs -> the count, the stance and the state. No replay starts here, /// so it holds for a persona with an expectation block too: the chain check needs the line valid, not a replay start. #[inline(never)] -fn check_checkpoint(p: &Persona, before: &str, cp: &str, at: usize) -> Result<(u64, Json, Box), (usize, String)> { +fn check_checkpoint(p: &Persona, canon: bool, before: Option<&str>, cp: &str, at: usize) -> Result<(u64, Json, Box, Option), (usize, String)> { let j = json::parse(cp).map_err(|e| (at, format!("not JSON: {}", e.msg)))?; - let b = json::parse(before).map_err(|e| (at - 1, format!("not JSON: {}", e.msg)))?; + // a segment's sealed line (no `before`: the event before it is in the previous segment) is taken on its link + let b = match before { Some(before) => json::parse(before).map_err(|e| (at - 1, format!("not JSON: {}", e.msg)))?, None => Json::Null }; if persona::canon(&j) != cp { return Err((at, "not canonical JSON".into())); } if let Some(k) = unknown_key(&j) { return Err((at, unsupported(k))); } - if !j.as_obj().is_some_and(|kv| kv.len() == 4 && kv.iter().all(|(k, _)| ["checkpoint", "prev", "stance", "state"].contains(&k.as_str()))) { - return Err((at, "checkpoint fields must be checkpoint, prev, stance and state".into())); + // from 0.14.0 a persona with an expectation block also carries `expect` (the canonical fold's memory) + let with = canon && p.expect.is_some(); + if !j.as_obj().is_some_and(|kv| kv.len() == 4 + usize::from(with) && kv.iter().all(|(k, _)| ["checkpoint", "prev", "stance", "state"].contains(&k.as_str()) || (with && k == "expect"))) { + return Err((at, if with { "checkpoint fields must be checkpoint, expect, prev, stance and state" } else { "checkpoint fields must be checkpoint, prev, stance and state" }.into())); } // §2.18: an event line of a persona with glyphs also carries `glyph`; RFC 0009: of a persona with an expectation block, `deviation` let extra = |kv: &[(String, Json)]| kv.iter().filter(|(k, _)| k == "glyph" || k == "deviation").count(); - if !b.as_obj().is_some_and(|kv| kv.len() == 5 + extra(kv) && kv.iter().all(|(k, _)| ["n", "inputs", "prev", "stance", "state", "glyph", "deviation"].contains(&k.as_str()))) { - return Err((at - 1, "a checkpoint must immediately follow an event line".into())); + if let Some(before) = before { + if !b.as_obj().is_some_and(|kv| kv.len() == 5 + extra(kv) && kv.iter().all(|(k, _)| ["n", "inputs", "prev", "stance", "state", "glyph", "deviation"].contains(&k.as_str()))) { + return Err((at - 1, "a checkpoint must immediately follow an event line".into())); + } + if j.get("prev").and_then(Json::as_str) != Some(persona::digest_of(before).as_str()) { return Err((at, "prev is not the sha256 of the line before".into())); } } - if j.get("prev").and_then(Json::as_str) != Some(persona::digest_of(before).as_str()) { return Err((at, "prev is not the sha256 of the line before".into())); } let n = j.get("checkpoint").and_then(Json::as_f64).filter(|x| *x >= 1.0 && x.fract() == 0.0).ok_or((at, "not a checkpoint line".to_string()))? as u64; - if b.get("n").and_then(Json::as_f64) != Some(n as f64) { return Err((at, format!("the line before is not event {n}"))); } + if before.is_some() && b.get("n").and_then(Json::as_f64) != Some(n as f64) { return Err((at, format!("the line before is not event {n}"))); } let stance = j.get("stance").cloned().unwrap_or(Json::Null); - if b.get("stance").and_then(Json::as_str) != Some(persona::stance_sha(&stance).as_str()) { return Err((at, "the checkpoint's stance is not the one event {n} logs".replace("{n}", &n.to_string()))); } + if before.is_some() && b.get("stance").and_then(Json::as_str) != Some(persona::stance_sha(&stance).as_str()) { return Err((at, "the checkpoint's stance is not the one event {n} logs".replace("{n}", &n.to_string()))); } let st = read_boxed(p, j.get("state").unwrap_or(&Json::Null)).map_err(|e| (at, read_err(e, "the checkpoint's state: ")))?; - if b.get("state").and_then(Json::as_str) != Some(st.digest.as_str()) { return Err((at, format!("the checkpoint's state is not the one event {n} logs"))); } - Ok((n, stance, st)) + if before.is_some() && b.get("state").and_then(Json::as_str) != Some(st.digest.as_str()) { return Err((at, format!("the checkpoint's state is not the one event {n} logs"))); } + let mem = if with { Some(persona::ExpectMem::read(p, j.get("expect").unwrap_or(&Json::Null)).map_err(|e| (at, format!("the checkpoint's expect: {e}")))?) } else { None }; + Ok((n, stance, st, mem)) } #[inline(never)] fn read_boxed(p: &Persona, j: &Json) -> Result, InErr> { State::read(p, j).map(Box::new) } @@ -619,9 +692,20 @@ fn check_chain_head(text: &str) -> Result<(Status, Box, Json), InErr> { let ls = lines(text); let err = |line: usize, why: String| perr("strand", format!("line {line}: {why}")); let (hp, h) = open_head(ls.first().copied().unwrap_or("")).map_err(|(i, why)| err(i, why))?; + let canon = !engine_version(h.get("engine").and_then(Json::as_str).unwrap_or("")).is_some_and(|v| v < DEV_R6_SINCE); let mut status = Status::Active; let mut n = 0u64; - for i in 1..ls.len() { + let mut from = 1; + // a segment: its second line is the sealed line, taken on its link (the header's `sealed`); the chain goes on from it + if let Some(sg) = h.get("segment") { + if hp.expect.is_some() && !canon { return Err(err(1, "a segment of a persona with an expectation block needs a strand of engine 0.14.0 or later".into())); } + let cp = ls.get(1).copied().unwrap_or(""); + if Some(persona::digest_of(cp).as_str()) != sg.get("sealed").and_then(Json::as_str) { return Err(err(2, SEALED.into())); } + let (cn, _, st, _) = check_checkpoint(&hp, canon, None, cp, 2).map_err(|(at, why)| err(at, why))?; + if Some(st.digest.as_str()) != h.get("state").and_then(|s| s.get("digest")).and_then(Json::as_str) { return Err(err(2, "the sealed line's state is not the segment header's".into())); } + n = cn; from = 2; + } + for i in from..ls.len() { let j = json::parse(ls[i]).map_err(|e| err(i + 1, e.msg))?; if persona::canon(&j) != ls[i] { return Err(err(i + 1, "not canonical JSON".into())); } if j.get("prev").and_then(Json::as_str) != Some(persona::digest_of(ls[i - 1]).as_str()) { @@ -654,7 +738,7 @@ fn check_chain_head(text: &str) -> Result<(Status, Box, Json), InErr> { if status != Status::Active { return Err(err(i + 1, format!("checkpoint while {}", status.name()))); } // Full state validation plus links to the immediately preceding event; no inference and no replay start, so a // persona with an expectation block (whose replay cannot start at a checkpoint) resumes after one as well - let (cn, _, _) = check_checkpoint(&hp, ls[i - 1], ls[i], i + 1).map_err(|(at, why)| err(at, why))?; + let (cn, _, _, _) = check_checkpoint(&hp, canon, Some(ls[i - 1]), ls[i], i + 1).map_err(|(at, why)| err(at, why))?; if cn != n { return Err(err(i + 1, "checkpoint event count differs".into())); } } } @@ -686,20 +770,26 @@ pub fn resume_boxed(text: &str, p: &Persona, st: Box, clock: Clock) -> Re match kind(&j) { Kind::Checkpoint => (j.get("checkpoint").and_then(Json::as_f64).unwrap_or(0.0) as u64, j.get("state").and_then(|s| s.get("digest")).and_then(Json::as_str).unwrap_or("").to_string()), _ => (j.get("n").and_then(Json::as_f64).unwrap_or(0.0) as u64, j.get("state").and_then(Json::as_str).unwrap_or("").to_string()) } }; if st.digest != want { return Err(perr("state", format!("not the strand's last state ({} != {})", &st.digest[..st.digest.len().min(19)], &want[..want.len().min(19)]))); } - // RFC 0009: the expectation's memory, folded from every event line's inputs and logged deviation - let expect = q.expect.as_ref().map(|_| expect_fold(&q, &lines)); + // RFC 0009: the expectation's memory, folded from every event line's inputs and logged deviation; from 0.14.0 from the last + // checkpoint's (the chain check read it), folding only the event lines after it let dev_full = engine_version(h.get("engine").and_then(Json::as_str).unwrap_or("")).is_some_and(|v| v < DEV_R6_SINCE); + let expect = if q.expect.is_none() { None } else { Some(match last_checkpoint(&lines, 0).filter(|_| !dev_full) { + Some(k) => { let j = json::parse(lines[k]).map_err(|e| bad(format!("line {} is not JSON: {}", k + 1, e.msg)))?; + let m = persona::ExpectMem::read(&q, j.get("expect").unwrap_or(&Json::Null)).map_err(|e| bad(format!("line {}: the checkpoint's expect: {e}", k + 1)))?; + expect_fold(&q, m, &lines[k + 1..]) } + None => expect_fold(&q, new_mem(dev_full), &lines[1..]) }) }; let mut lv = Live::boxed(q, st, clock, persona::digest_of(last), n, expect, dev_full); for c in tail.iter().rev() { let what = c.get("control").and_then(Json::as_str).unwrap_or(""); lv.status = lv.status.after(what).unwrap_or(Status::Retired); lv.st = lv.st.zero_credit(&lv.p); lv.controls += 1; } Ok(lv) } -/// RFC 0009: the expectation's memory folded from the event lines after the header, one line read at a time +/// The expectation's empty memory: the canonical fold from 0.14.0 (`dev_full` false), the fold before it otherwise +fn new_mem(dev_full: bool) -> persona::ExpectMem { if dev_full { persona::ExpectMem::new() } else { persona::ExpectMem::canonical() } } +/// RFC 0009: the expectation's memory `m` with the event lines of `lines` folded in, one line read at a time #[inline(never)] -fn expect_fold(q: &Persona, lines: &[&str]) -> persona::ExpectMem { - let mut m = persona::ExpectMem::new(); - for l in lines.iter().skip(1) { if let Ok(j) = json::parse(l) { if matches!(kind(&j), Kind::Event) { let d = j.get("deviation"); +fn expect_fold(q: &Persona, mut m: persona::ExpectMem, lines: &[&str]) -> persona::ExpectMem { + for l in lines.iter() { if let Ok(j) = json::parse(l) { if matches!(kind(&j), Kind::Event) { let d = j.get("deviation"); let lp = d.filter(|d| d.get("cold").is_none()).and_then(|d| d.get("pred")).and_then(Json::as_f64); persona::expect_step(q, &mut m, j.get("inputs").unwrap_or(&Json::Null), lp); } } } m @@ -710,7 +800,7 @@ fn expect_fold(q: &Persona, lines: &[&str]) -> persona::ExpectMem { /// on the recorded life. pub fn bound_audit(text: &str, b: &persona::bound::Bound, eng: Engine) -> Result<(Json, bool), (usize, String)> { let lines = lines(text); - let mut r = Replay::open(lines.first().copied().unwrap_or(""))?; + let (mut r, k0) = Replay::open_lines(&lines)?; let p = r.live.p.clone(); let (traits, cap) = persona::bound::learned_traits(&p).ok_or((1, "the strand's persona has no learning block: nothing to audit".to_string()))?; let rest = b.rest_hours.unwrap_or_else(|| persona::bound::default_rest(&p)); let seed = r.live.st.seed; let e = |x: InErr| (0usize, format!("{}: {}", x.path, x.msg)); @@ -724,7 +814,7 @@ pub fn bound_audit(text: &str, b: &persona::bound::Bound, eng: Engine) -> Result let measure = |st: &persona::State| -> Result, (usize, String)> { let a = persona::bound::rest_odds(&p, st, rest, eng).map_err(e)?; let z = persona::bound::rest_odds(&p, &persona::bound::with_learned(&p, st, &persona::bound::learned_of(st).iter().map(|d| vec![0.0; d.len()]).collect::>()), rest, eng).map_err(e)?; Ok(a.iter().zip(&z).map(|(x, y)| persona::bound::tv(x, y)).collect()) }; - for l in lines.iter().skip(1) { if l.trim().is_empty() { continue; } + for l in lines.iter().skip(k0) { if l.trim().is_empty() { continue; } let was_event = r.step(l, eng)?.is_some(); if was_event { events += 1; } else if l.contains(r#""control":"erase""#) { erasures += 1; } let now = persona::bound::learned_of(&r.live.st); if now != last { if was_event { feedback += 1; } last = now; } let tvs = measure(&r.live.st)?; From 0923d7fa3065cc6981b211d56d0a4de434384ece Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 03:13:39 -0700 Subject: [PATCH 13/61] probbit-cli: monitor opens a segment at its sealed line when it has no later checkpoint (the header's link), as live verify does; monitor --once draws each segment of a life --- probbit-cli/src/monitor.rs | 9 ++++++--- probbit-cli/tests/live.rs | 2 ++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/probbit-cli/src/monitor.rs b/probbit-cli/src/monitor.rs index 43a6424..a4bf009 100644 --- a/probbit-cli/src/monitor.rs +++ b/probbit-cli/src/monitor.rs @@ -131,14 +131,17 @@ impl Watch { /// before 0.14.0 a persona with an expectation block folds every event line into its memory, which no checkpoint carries, so /// its strand replays from the header (every line, slower; `open_at` refuses that shortcut); from 0.14.0 the checkpoint carries /// the memory, and every strand starts at the last one + /// A segment (§5.7) starts at its sealed line (line 2) when it has no later checkpoint. fn start(lines: &[&str]) -> Option { - let k = crate::live::last_checkpoint(lines, 0).filter(|k| *k >= 2)?; - (!Replay::open(lines[0]).is_ok_and(|r| r.live.p.expect.is_some() && r.live.dev_full)).then_some(k) + let k = crate::live::last_checkpoint(lines, 0)?; let r = Replay::open(lines[0]); + if k < 2 && !r.as_ref().is_ok_and(|r| r.header.get("segment").is_some()) { return None; } + (!r.is_ok_and(|r| r.live.p.expect.is_some() && r.live.dev_full)).then_some(k) } /// The replay from a checkpoint line (`live::Replay::open_at`; `before` = the event line before it): the lines before it are /// not replayed, and the board starts at the checkpoint's event (its stance, the inputs `before` logs, its learned deltas) fn open_at(head: &str, before: &str, cp: &str, at: usize) -> Result { - let rep = Replay::open_at(head, before, cp, at)?; let meta = Meta::of(&rep); let n = meta.moods.len(); + // a segment's sealed line (line 2, after the header) opens on the header's link + let rep = if at == 2 { Replay::open_lines(&[head, cp])?.0 } else { Replay::open_at(head, before, cp, at)? }; let meta = Meta::of(&rep); let n = meta.moods.len(); let mut w = Watch { rep, meta, last: None, spark: vec![VecDeque::new(); n], bad: None }; if let Some(doc) = w.rep.last_stance().cloned() { let given = json::parse(before).ok().and_then(|j| j.get("inputs").cloned()).unwrap_or(Json::Null); diff --git a/probbit-cli/tests/live.rs b/probbit-cli/tests/live.rs index f2ff55f..e49d589 100644 --- a/probbit-cli/tests/live.rs +++ b/probbit-cli/tests/live.rs @@ -709,6 +709,8 @@ fn a_segmented_life_equals_one_file_and_verify_chain_walks_back() { assert_eq!(texts[k].lines().nth(1), texts[k - 1].lines().last(), "segment {k} repeats the sealed line"); } let (_, vone, _) = probbit(&["live", "verify", &one]); let vone = parse(&vone); for k in 0..3 { let (c, v, e) = probbit(&["live", "verify", &seg(k)]); assert_eq!(c, 0, "segment {k}: {e}"); if k > 0 { assert!(v.contains(&format!(r#""segment":{k}"#)) && v.contains("trusted_link"), "{v}"); } } + // `monitor --once` draws a segment from its last checkpoint, or from its sealed line when it has no later one + for k in 1..3 { let (c, o, e) = probbit(&["monitor", &seg(k), "--once", "--plain"]); assert_eq!(c, 0, "segment {k}: {e}{o}"); assert!(o.contains(if k == 1 { "event 100" } else { "event 120" }), "segment {k}: {o}"); } let (c, v, e) = probbit(&["live", "verify", &seg(2), "--chain"]); assert_eq!(c, 0, "{e}"); let v = parse(&v); assert_eq!(s(&v, &["segments"]), &Json::Num(3.0)); assert_eq!(s(&v, &["final_state"]), s(&vone, &["final_state"])); assert_eq!(s(&v, &["last_line"]), s(&vone, &["last_line"])); // the link broken: the first segment's sealed line edited (kept canonical): the chain breaks there, named From ef73d3b708422af92ad5263f526ca667e226829e Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 05:30:55 -0700 Subject: [PATCH 14/61] Version 0.14.0-dev: the engine identifies as probbit 0.14.0-dev (strand headers, verified_by, --version), so the strands this build writes get the 0.14.0 behaviours (rounded deviation, canonical fold, expect on checkpoints, segments of a persona with an expectation block); Cargo.toml, the path dependencies, Cargo.lock, the manual page; the CLI segment test now segments an expectation persona and walks its chain; CHANGELOG --- CHANGELOG.md | 4 ++++ Cargo.lock | 14 +++++++------- Cargo.toml | 2 +- docs/probbit.1 | 2 +- probbit-cli/Cargo.toml | 8 ++++---- probbit-cli/tests/live.rs | 17 +++++++++++------ probbit-core/Cargo.toml | 2 +- probbit-decide/Cargo.toml | 6 +++--- probbit-ir/Cargo.toml | 4 ++-- probbit-persona/Cargo.toml | 8 ++++---- 10 files changed, 38 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52815f7..e4722e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project are documented here. The format follows ## Unreleased +- Development build: the workspace version is `0.14.0-dev` (`probbit --version`, the `engine` of every strand header this + build writes, `verified_by`), so the strands it writes get the 0.14.0 behaviours below (the rounded deviation, the + canonical fold, `expect` on checkpoints, segments of a persona with an expectation block). Strands of 0.13.0 and earlier + replay as their engine wrote them. - **`probbit-persona`, the document layer and the strand writer as a library crate** (docs/embedding.md): `json`, `yaml`, `persona`, `run`, `router` and `evaluate` (feature `decide`), `sys`, and `strand` (the pure half of `probbit live`: `Live`, the line writers, `Replay`, `verify*`, with no file I/O) move out of probbit-cli, which uses them unchanged; every CLI output is diff --git a/Cargo.lock b/Cargo.lock index aea55fc..4d8aba5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,7 +40,7 @@ dependencies = [ [[package]] name = "probbit-cli" -version = "0.13.0" +version = "0.14.0-dev" dependencies = [ "probbit-core", "probbit-decide", @@ -50,14 +50,14 @@ dependencies = [ [[package]] name = "probbit-core" -version = "0.13.0" +version = "0.14.0-dev" dependencies = [ "probbit-nostd", ] [[package]] name = "probbit-decide" -version = "0.13.0" +version = "0.14.0-dev" dependencies = [ "probbit-core", "probbit-ir", @@ -66,7 +66,7 @@ dependencies = [ [[package]] name = "probbit-ir" -version = "0.13.0" +version = "0.14.0-dev" dependencies = [ "probbit-core", "probbit-nostd", @@ -74,7 +74,7 @@ dependencies = [ [[package]] name = "probbit-nostd" -version = "0.13.0" +version = "0.14.0-dev" dependencies = [ "hashbrown", "libm", @@ -83,7 +83,7 @@ dependencies = [ [[package]] name = "probbit-persona" -version = "0.13.0" +version = "0.14.0-dev" dependencies = [ "probbit-core", "probbit-decide", @@ -93,7 +93,7 @@ dependencies = [ [[package]] name = "probbit-wasm" -version = "0.13.0" +version = "0.14.0-dev" dependencies = [ "probbit-core", "probbit-decide", diff --git a/Cargo.toml b/Cargo.toml index 55670a8..35934af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["probbit-nostd", "probbit-core", "probbit-ir", "probbit-decide", "probbit-persona", "probbit-cli", "probbit-wasm"] [workspace.package] -version = "0.13.0" +version = "0.14.0-dev" edition = "2021" license = "Apache-2.0" rust-version = "1.78" diff --git a/docs/probbit.1 b/docs/probbit.1 index 70f2a8c..e8af66f 100644 --- a/docs/probbit.1 +++ b/docs/probbit.1 @@ -1,4 +1,4 @@ -.TH PROBBIT 1 "" "probbit 0.13.0" "probbit manual" +.TH PROBBIT 1 "" "probbit 0.14.0-dev" "probbit manual" .SH NAME probbit \- a virtual probabilistic (p\-bit) processor: joint decisions under hard rules, with gated odds .SH SYNOPSIS diff --git a/probbit-cli/Cargo.toml b/probbit-cli/Cargo.toml index a00b6e6..96098c1 100644 --- a/probbit-cli/Cargo.toml +++ b/probbit-cli/Cargo.toml @@ -19,7 +19,7 @@ path = "src/main.rs" turn_trace = ["probbit-persona/turn_trace"] [dependencies] -probbit-core = { path = "../probbit-core", version = "0.13.0" } -probbit-decide = { path = "../probbit-decide", version = "0.13.0" } -probbit-ir = { path = "../probbit-ir", version = "0.13.0" } -probbit-persona = { path = "../probbit-persona", version = "0.13.0" } +probbit-core = { path = "../probbit-core", version = "0.14.0-dev" } +probbit-decide = { path = "../probbit-decide", version = "0.14.0-dev" } +probbit-ir = { path = "../probbit-ir", version = "0.14.0-dev" } +probbit-persona = { path = "../probbit-persona", version = "0.14.0-dev" } diff --git a/probbit-cli/tests/live.rs b/probbit-cli/tests/live.rs index e49d589..c714df4 100644 --- a/probbit-cli/tests/live.rs +++ b/probbit-cli/tests/live.rs @@ -687,7 +687,7 @@ fn a_strand_with_checkpoints_resumes_with_and_without_an_expectation_block() { /// lines, each segment's header and repeated sealed line set aside, are the life logged to one file with a checkpoint after every /// 50th event; the stances and the state are the same; each segment verifies on its own (from its header's link) and /// `verify --chain` walks back through all three; a previous segment whose last line is not the sealed line exits 1 naming that -/// file, a missing one exits 6 naming it; before 0.14.0 a persona with an expectation block cannot be segmented +/// file, a missing one exits 6 naming it; a persona with an expectation block segments too (this engine writes 0.14.0-on strands) #[test] fn a_segmented_life_equals_one_file_and_verify_chain_walks_back() { let evs: Vec = (0..120).map(|t| match t % 3 { 0 => r#"{"elapsed_hours":1,"praise":true}"#, 1 => r#"{"elapsed_hours":0.5,"error":true}"#, _ => r#"{"elapsed_hours":2}"# }.to_string()).collect(); @@ -719,9 +719,14 @@ fn a_segmented_life_equals_one_file_and_verify_chain_walks_back() { std::fs::remove_file(seg(1)).unwrap(); let (c, v, _) = probbit(&["live", "verify", &seg(2), "--chain"]); assert_eq!(c, 6, "{v}"); assert!(v.contains(r#""missing":"life.0001.strand""#) && v.contains(r#""segment_file":"life.0002.strand""#), "{v}"); - // before 0.14.0 (this engine's strands) an expectation persona is not segmented - let (st3, f3) = (init("probbit-cli/tests/fixtures/monitor/tutor-expect.yaml", 2, "seg-x.json"), format!("{dir}/x.strand")); - let (c, _, e) = probbit(&["live", "probbit-cli/tests/fixtures/monitor/tutor-expect.yaml", "--state", &st3, "--clock", "fixed", "--events", &a, "--strand", &f3, "--segment-every", "50"]); - assert_eq!(c, 2, "{e}"); assert!(e.contains("--segment-every"), "{e}"); - let _ = std::fs::remove_dir_all(&dir); clean(&[&all, &a, &b, &st1, &one, &st2, &st3]); + // this engine (0.14.0 on) segments an expectation persona: each checkpoint carries the memory, so each segment verifies + // from its sealed line and the chain walks back + let x = "probbit-cli/tests/fixtures/monitor/tutor-expect.yaml"; + let xev: Vec = (0..60).map(|t| format!(r#"{{"elapsed_hours":1,"praise":{}}}"#, t % 3 == 0)).collect(); let xa = events_file("seg-x.jsonl", &xev); + let (st3, f3) = (init(x, 2, "seg-x.json"), format!("{dir}/x.strand")); + let (c, _, e) = probbit(&["live", x, "--state", &st3, "--clock", "fixed", "--events", &xa, "--strand", &f3, "--segment-every", "25"]); assert_eq!(c, 0, "{e}"); + let h = parse(read(&f3).lines().next().unwrap()); assert_eq!(s(&h, &["engine"]), &Json::Str(THIS.into()), "the header names this engine"); + assert!(read(&format!("{dir}/x.0001.strand")).lines().nth(1).is_some_and(|l| l.contains(r#""expect":{"#)), "the sealed line carries the memory"); + let (c, v, e) = probbit(&["live", "verify", &format!("{dir}/x.0002.strand"), "--chain"]); assert_eq!(c, 0, "{e}{v}"); assert_eq!(s(&parse(&v), &["segments"]), &Json::Num(3.0)); + let _ = std::fs::remove_dir_all(&dir); clean(&[&all, &a, &b, &st1, &one, &st2, &st3, &xa]); } diff --git a/probbit-core/Cargo.toml b/probbit-core/Cargo.toml index b1f3496..7de01ac 100644 --- a/probbit-core/Cargo.toml +++ b/probbit-core/Cargo.toml @@ -17,4 +17,4 @@ std = [] nostd = ["dep:probbit-nostd"] [dependencies] -probbit-nostd = { path = "../probbit-nostd", version = "0.13.0", optional = true } +probbit-nostd = { path = "../probbit-nostd", version = "0.14.0-dev", optional = true } diff --git a/probbit-decide/Cargo.toml b/probbit-decide/Cargo.toml index 9989b3c..9da17e3 100644 --- a/probbit-decide/Cargo.toml +++ b/probbit-decide/Cargo.toml @@ -17,6 +17,6 @@ std = ["probbit-core/std", "probbit-ir/std"] nostd = ["dep:probbit-nostd", "probbit-core/nostd", "probbit-ir/nostd"] [dependencies] -probbit-nostd = { path = "../probbit-nostd", version = "0.13.0", optional = true } -probbit-core = { path = "../probbit-core", default-features = false, version = "0.13.0" } -probbit-ir = { path = "../probbit-ir", default-features = false, version = "0.13.0" } +probbit-nostd = { path = "../probbit-nostd", version = "0.14.0-dev", optional = true } +probbit-core = { path = "../probbit-core", default-features = false, version = "0.14.0-dev" } +probbit-ir = { path = "../probbit-ir", default-features = false, version = "0.14.0-dev" } diff --git a/probbit-ir/Cargo.toml b/probbit-ir/Cargo.toml index fbb6728..1ba234d 100644 --- a/probbit-ir/Cargo.toml +++ b/probbit-ir/Cargo.toml @@ -17,5 +17,5 @@ std = ["probbit-core/std"] nostd = ["dep:probbit-nostd", "probbit-core/nostd"] [dependencies] -probbit-nostd = { path = "../probbit-nostd", version = "0.13.0", optional = true } -probbit-core = { path = "../probbit-core", default-features = false, version = "0.13.0" } +probbit-nostd = { path = "../probbit-nostd", version = "0.14.0-dev", optional = true } +probbit-core = { path = "../probbit-core", default-features = false, version = "0.14.0-dev" } diff --git a/probbit-persona/Cargo.toml b/probbit-persona/Cargo.toml index 142b857..bb64a72 100644 --- a/probbit-persona/Cargo.toml +++ b/probbit-persona/Cargo.toml @@ -21,7 +21,7 @@ decide = ["dep:probbit-decide"] turn_trace = [] [dependencies] -probbit-nostd = { path = "../probbit-nostd", version = "0.13.0", optional = true } -probbit-core = { path = "../probbit-core", default-features = false, version = "0.13.0" } -probbit-ir = { path = "../probbit-ir", default-features = false, version = "0.13.0" } -probbit-decide = { path = "../probbit-decide", default-features = false, version = "0.13.0", optional = true } +probbit-nostd = { path = "../probbit-nostd", version = "0.14.0-dev", optional = true } +probbit-core = { path = "../probbit-core", default-features = false, version = "0.14.0-dev" } +probbit-ir = { path = "../probbit-ir", default-features = false, version = "0.14.0-dev" } +probbit-decide = { path = "../probbit-decide", default-features = false, version = "0.14.0-dev", optional = true } From 17347d73256aa5562ef23eb39f06fe55a14fbad7 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 05:41:33 -0700 Subject: [PATCH 15/61] probbit-cli: live control STRAND seal (a host seals when it decides: the newest segment replayed, refused unless its last line is an event line, the sealing checkpoint appended, the next segment file written and printed); live --strand always continues the newest segment, so a sealed file is never appended to; monitor --dir serves a life's NAME.strand and NAME.000k.strand as one strand NAME (the life's count, the newest file's segment, the base name's sidecar) and follows its newest file across a seal; docs, manual page, CHANGELOG --- CHANGELOG.md | 7 +++++++ docs/persona.md | 19 +++++++++++++++---- docs/probbit.1 | 8 ++++---- probbit-cli/src/live.rs | 31 +++++++++++++++++++++++++++++++ probbit-cli/src/main.rs | 21 ++++++++++++++------- probbit-cli/src/monitor.rs | 35 ++++++++++++++++++++++++++++------- probbit-cli/tests/live.rs | 27 +++++++++++++++++++++++++++ probbit-cli/tests/monitor.rs | 32 ++++++++++++++++++++++++++++++++ 8 files changed, 158 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4722e5..5f6e904 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,13 @@ All notable changes to this project are documented here. The format follows checks one segment on its link (summary `segment`, `trusted_link`); `live verify SEGMENT --chain` walks back to the first file (exit 1 at a break, naming the file; exit 6 when a previous file is missing). `strand::segment_header`, `strand::segment_name`, `strand::write_header_seg`, `strand::Replay::open_lines`. +- **`probbit live control STRAND seal`** (docs/persona.md section 5.7): a host seals when it decides. Under the writer lock it + replays the newest segment, refuses unless its last line is an event line (exit 2, nothing written), appends the sealing + checkpoint, writes the next segment file and prints it (`file`). `probbit live ... --strand NAME.strand` now always continues + the newest segment that exists, with or without `--segment-every`, so a sealed file is never appended to. +- **`probbit monitor --dir` serves a life in segments as one strand**: `NAME.strand` and `NAME.0001.strand`, ... (without a gap) + are listed once, id `NAME`, with the life's event count, the newest file's `segment` index and the base name's sidecar; the + page follows the newest file and goes on in the next one within the 1 s scan after a seal, its event count carrying on. - **`turn_trace`** (feature of probbit-persona and probbit-cli, off by default): `persona::trace` records the last event's microseconds and a host probe's value per phase (input, expect, prep, compile, solve, decode, document, line, fold), so a firmware measures the same breakdown on its hardware; `probbit live` in such a build prints it on stderr when diff --git a/docs/persona.md b/docs/persona.md index 804e73d..87ac6ed 100644 --- a/docs/persona.md +++ b/docs/persona.md @@ -1569,9 +1569,20 @@ a new file: the sealing line is a checkpoint line (the last line of the segment) `"segment": {"index": k, "previous": "", "sealed": "sha256:"}`, its state the sealed state, and its second line is the sealing line itself, byte for byte. The chain goes on across the files unchanged: the lines of all segments, each segment's header and repeated sealing line set aside, are the strand the same life writes to one -file with a checkpoint at each seal. The labels sidecar is the first file's (`NAME.labels.json`); a run with `--segment-every` -continues the newest segment that exists. Before 0.14.0 a persona with an expectation block is not segmented (its checkpoint -does not carry the memory). +file with a checkpoint at each seal. The labels sidecar is the first file's (`NAME.labels.json`); a run on `--strand +NAME.strand` continues the newest segment that exists (a sealed file is never appended to). Before 0.14.0 a persona with an +expectation block is not segmented (its checkpoint does not carry the memory). + +A host that decides when to seal runs `probbit live control NAME.strand seal` (under the writer lock, like any control): it +replays the newest segment (from its last checkpoint), refuses unless that file's last line is an event line (a seal follows +an event: a seal right after a seal or a checkpoint exits 2 and writes nothing), appends the sealing checkpoint, writes the +next segment file (its header, then the sealing line) and prints `{"ok": true, "control": "seal", "segment": k, "file": +"", "line", "head"}`. It writes no control line, so it takes no `--by` or `--reason`. + +`probbit monitor --dir DIR --serve` lists a life's files (`NAME.strand`, then `NAME.0001.strand`, ... without a gap) as one +strand, id `NAME`, with the life's event count, `"segment": k` (the newest file's index) and the base name's sidecar; its page +follows the newest file and goes on in the next one within the 1 s scan after a seal (the new file opens at its sealing line, +the event already shown). A segment file given on its own still opens at its sealing line. - `live verify SEGMENT` checks one segment on its link: the header is as written, the second line's sha256 is the header's `sealed`, that line is a valid checkpoint (its state is the header's, its memory reads), and every line after it replays. @@ -1702,7 +1713,7 @@ cannot read the documents: | `/` | the page: one file embedded in the binary (under 72 KB, styles and script inline; it fetches no fonts, styles or scripts), with a Content-Security-Policy that lets it connect to its own server and nowhere else | | `/events` | server-sent events: `meta` (the layout: the strand, the engine, the persona, its traits, moods, inputs and history features in the persona's order, and `face`, the two moods the face's meters show), the latest `frame`, then a frame per event: `{n, doc, given, learned, spark, diverges, note}` (with `engine.trajectory` also `trajectory` and `settle`, below), that is the stance document, the inputs as the strand logs them, the learned deltas, the moods' recent positions, the line that differs and a warning; a heartbeat comment after 15 quiet seconds; events that land within one poll (100 ms), or while a page falls behind, come as the latest frame | | `/doc/N` | event N's stance document, canonical JSON and a newline, as `probbit live` printed it (its sha256 is the `stance` digest of the strand's line N + 1); the latest 10,000 are kept | -| `/strands` | the strands served, canonical JSON: `{demo, dir, strands: [{id, strand, labels, persona, n, glyph, age_s, labels_error?}]}` (the file name, never its directory; `age_s`: seconds since the strand file was last written); the demo lists none | +| `/strands` | the strands served, canonical JSON: `{demo, dir, strands: [{id, strand, labels, persona, n, glyph, age_s, labels_error?, segment?}]}` (the file name, never its directory; `age_s`: seconds since the strand file was last written, a life in segments: its newest file; `segment`: that file's index, when it is not the first); the demo lists none | | `/s/ID/events`, `/s/ID/doc/N` | as `/events` and `/doc/N`, for strand ID; `/events` and `/doc/N` are the first strand's, so a single-strand page is as before | **Who and where: labels, many strands.** A strand says which persona and engine; it does not say which agent, on which diff --git a/docs/probbit.1 b/docs/probbit.1 index e8af66f..3085f0a 100644 --- a/docs/probbit.1 +++ b/docs/probbit.1 @@ -364,15 +364,15 @@ the host's action log, one {"turn": T, "action": {trait: level}} per line: compa write each event's block (the text a host injects, RFC 0008 E: every trait and mood as `name level`, then the viability line) to FILE, one {"block", "n"} per line; replays from the header .RE .TP -\fBcontrol\fR STRAND pause|resume|retire|erase \-\-by WHO \-\-reason TEXT [\-\-at TIME] -pause, resume or retire an individual, or erase its learned weights (RFC 0004: every learned weight back to 0, the status unchanged); appends a control line under the lock: while paused or retired every event is refused (exit 4); no credit crosses a control line; retire is final; `verify` replays them and reports the status +\fBcontrol\fR STRAND pause|resume|retire|erase \-\-by WHO \-\-reason TEXT [\-\-at TIME] | STRAND seal +pause, resume or retire an individual, or erase its learned weights (RFC 0004: every learned weight back to 0, the status unchanged); appends a control line under the lock: while paused or retired every event is refused (exit 4); no credit crosses a control line; retire is final; `verify` replays them and reports the status. `seal` (section 5.7) seals the newest segment of STRAND: refused unless its last line is an event line, it appends the sealing checkpoint and writes the next segment file (its header links the sealed line, then the sealed line itself), and prints that file in `file` .RS 4 .TP \fB\-\-by\fR \fIWHO\fR -who decided it: human:ID or env:ID (required) +who decided it: human:ID or env:ID (required, except for seal) .TP \fB\-\-reason\fR \fITEXT\fR -why (required) +why (required, except for seal) .TP \fB\-\-at\fR \fITIME\fR when (default: now, UTC) diff --git a/probbit-cli/src/live.rs b/probbit-cli/src/live.rs index e4d5663..23a1c3d 100644 --- a/probbit-cli/src/live.rs +++ b/probbit-cli/src/live.rs @@ -127,6 +127,37 @@ pub fn control_cmd(path: &str, what: &str, by: &str, reason: &str, at: &str) -> ("head".into(), s(&persona::digest_of(&line)))])) } +/// `probbit live control STRAND seal` (§5.7, "Segments"): a host decides when to seal. Under the writer lock, the newest segment +/// of STRAND (`NAME.strand`, `NAME.0001.strand`, ...) is replayed (from its last checkpoint, else from its start); refused unless +/// its last line is an event line (a seal follows an event); then the sealing checkpoint is appended to it and the next segment +/// written: its header (`segment` {index, previous, sealed}) and the sealing line -> {ok, control: seal, segment, file, line, head} +pub fn seal_cmd(path: &str, eng: Engine) -> Result { + let lock = Lock::take(path)?; + let mut k = 0u64; while std::path::Path::new(&segment_name(path, k + 1)).exists() { k += 1; } + let cur = segment_name(path, k); + let text = std::fs::read_to_string(&cur).map_err(|e| perr("strand", format!("cannot read {cur}: {e}")))?; + if !text.ends_with('\n') { return Err(perr("strand", format!("{cur}: its last line is incomplete (no newline at the end)"))); } + let ls = lines(&text); + let last = ls.last().and_then(|l| json::parse(l).ok()).filter(|_| ls.len() > 1); + if !last.as_ref().is_some_and(|j| matches!(kind(j), Kind::Event)) { return Err(perr("strand", format!("{cur}: its last line is not an event line (a seal follows an event)"))); } + let at = |(i, why): (usize, String)| perr("strand", format!("{cur}: line {i}: {why}")); + let (mut r, from) = match last_checkpoint(&ls, 0).filter(|&c| c >= 2).and_then(|c| Replay::open_at(ls[0], ls[c - 1], ls[c], c + 1).ok().map(|r| (r, c + 1))) { + Some(x) => x, None => Replay::open_lines(&ls).map_err(at)? }; + for l in &ls[from..] { r.step(l, eng).map_err(at)?; } + if r.live.expect.as_ref().is_some_and(|m| !m.is_canonical()) { return Err(perr("strand", "before engine 0.14.0 a checkpoint does not carry the expectation's memory, so a segment of a persona with an expectation block could not start from its sealed line")); } + let stance = r.last_stance().cloned().ok_or_else(|| perr("strand", "no event replayed"))?; + let cp = r.live.checkpoint(&stance); + let next = segment_name(path, k + 1); + let pname = std::path::Path::new(&cur).file_name().map_or_else(|| cur.clone(), |f| f.to_string_lossy().into_owned()); + let engine = r.header.get("engine").and_then(Json::as_str).unwrap_or("").to_string(); + let h = segment_header(&r.live, &r.doc, &engine, k + 1, &pname, &persona::digest_of(&cp)); + if !lock.held() { return Err(locked(None, &format!("{path}.lock"))); } + append(&cur, &[&cp])?; append(&next, &[&h, &cp])?; + let s = |x: &str| Json::Str(x.to_string()); + Ok(Json::Obj(vec![("ok".into(), Json::Bool(true)), ("control".into(), s("seal")), ("segment".into(), Json::Num((k + 1) as f64)), ("file".into(), s(&next)), + ("line".into(), Json::Num((ls.len() + 1) as f64)), ("head".into(), s(&persona::digest_of(&cp)))])) +} + /// The tools `probbit_live_event` and `probbit_live_verify` (`probbit mcp`), stateless as the persona tools. event: the persona /// (a document or a path), the state (or a seed: a new individual), one event (its inputs; `elapsed_hours` comes from the host's /// clock and is quantised here) and optionally a strand file on the server's disk (a new one gets the header; an existing one is diff --git a/probbit-cli/src/main.rs b/probbit-cli/src/main.rs index 562c86f..f53b0f4 100644 --- a/probbit-cli/src/main.rs +++ b/probbit-cli/src/main.rs @@ -319,10 +319,10 @@ pub(crate) static COMMANDS: &[Command] = &[ Flag { name: "--chain", arg: "", help: "a segment (section 5.7): verify it and walk back through the previous segment files to the first, each verified and its last line the sealed line the next one links; exit 1 at the first break (the segment file named), 6 when a previous segment file is missing" }, Flag { name: "--actions", arg: "FILE", help: "the host's action log, one {\"turn\": T, \"action\": {trait: level}} per line: compared with each turn's released levels (summary `actions`: checked, differs); exit 1 when one differs" }, Flag { name: "--blocks", arg: "FILE", help: "write each event's block (the text a host injects, RFC 0008 E: every trait and mood as `name level`, then the viability line) to FILE, one {\"block\", \"n\"} per line; replays from the header" }] }, - Sub { name: "control", args: "STRAND pause|resume|retire|erase --by WHO --reason TEXT [--at TIME]", - help: "pause, resume or retire an individual, or erase its learned weights (RFC 0004: every learned weight back to 0, the status unchanged); appends a control line under the lock: while paused or retired every event is refused (exit 4); no credit crosses a control line; retire is final; `verify` replays them and reports the status", - flags: &[Flag { name: "--by", arg: "WHO", help: "who decided it: human:ID or env:ID (required)" }, - Flag { name: "--reason", arg: "TEXT", help: "why (required)" }, + Sub { name: "control", args: "STRAND pause|resume|retire|erase --by WHO --reason TEXT [--at TIME] | STRAND seal", + help: "pause, resume or retire an individual, or erase its learned weights (RFC 0004: every learned weight back to 0, the status unchanged); appends a control line under the lock: while paused or retired every event is refused (exit 4); no credit crosses a control line; retire is final; `verify` replays them and reports the status. `seal` (section 5.7) seals the newest segment of STRAND: refused unless its last line is an event line, it appends the sealing checkpoint and writes the next segment file (its header links the sealed line, then the sealed line itself), and prints that file in `file`", + flags: &[Flag { name: "--by", arg: "WHO", help: "who decided it: human:ID or env:ID (required, except for seal)" }, + Flag { name: "--reason", arg: "TEXT", help: "why (required, except for seal)" }, Flag { name: "--at", arg: "TIME", help: "when (default: now, UTC)" }] }, ], list_subs: true, shared: false, @@ -863,8 +863,14 @@ fn live_cmd(args: &[String]) { } if args.get(1).map(String::as_str) == Some("control") { let mut a = vec!["live control".to_string()]; a.extend(args.iter().skip(4).cloned()); let (v, w) = sub_flags("live", "control").unwrap(); check_flags(&a, &v, &w); - let usage = "live control: probbit live control STRAND pause|resume|retire|erase --by human:ID --reason TEXT"; + let usage = "live control: probbit live control STRAND pause|resume|retire|erase --by human:ID --reason TEXT (or STRAND seal)"; let (Some(path), Some(what)) = (args.get(2).filter(|a| !a.starts_with("--")), args.get(3).filter(|a| !a.starts_with("--"))) else { fail(usage) }; + // seal (§5.7): no control line, so no --by, --reason or --at; the sealing checkpoint and the next segment file + if what == "seal" { + if let Some(f) = ["--by", "--reason", "--at"].iter().find(|f| args.iter().any(|a| a == *f)) { fail(&format!("live control STRAND seal: {f} does not apply (a seal writes a checkpoint line, not a control line)")) } + match live::seal_cmd(path, eng) { Ok(doc) => emit(&persona::canon(&doc)), Err(e) => refuse("live control: ", e) } + return; + } let (Some(by), Some(reason)) = (opt("--by"), opt("--reason")) else { fail(&format!("{usage} (--by and --reason are required)")) }; match live::control_cmd(path, what, &by, &reason, &opt("--at").unwrap_or_else(live::utc_now)) { Ok(doc) => emit(&persona::canon(&doc)), Err(e) => refuse("live control: ", e) } return; @@ -901,8 +907,9 @@ fn live_cmd(args: &[String]) { let strand = opt("--strand"); let lock = strand.as_ref().map(|f| live::Lock::take(f).unwrap_or_else(|e| refuse("live: ", e))); if let Some(f) = &strand { if std::path::Path::new(f).exists() && state_file.is_none() { fail(&format!("live: {f} exists: continue it with --state FILE (the state after its last line), or log to a new file")) } } - // --segment-every: the run continues the newest segment of the strand (NAME.strand, then NAME.0001.strand, ...) - let mut seg = 0u64; if seg_every > 0 { if let Some(f) = &strand { while std::path::Path::new(&live::segment_name(f, seg + 1)).exists() { seg += 1; } } } + // the run continues the newest segment of the strand (NAME.strand, then NAME.0001.strand, ...), sealed by --segment-every + // or by `live control STRAND seal`: a sealed file is never appended to + let mut seg = 0u64; if let Some(f) = &strand { while std::path::Path::new(&live::segment_name(f, seg + 1)).exists() { seg += 1; } } let cur = std::cell::RefCell::new(strand.as_ref().map(|f| live::segment_name(f, seg))); // the strand's engine (its header's; a new strand: this one's): the next segment's header names it let strand_engine = cur.borrow().as_ref().and_then(|f| std::fs::read_to_string(f).ok()).and_then(|t| json::parse(live::lines(&t).first().copied().unwrap_or("")).ok()) diff --git a/probbit-cli/src/monitor.rs b/probbit-cli/src/monitor.rs index a4bf009..d29ceb1 100644 --- a/probbit-cli/src/monitor.rs +++ b/probbit-cli/src/monitor.rs @@ -882,7 +882,8 @@ fn publish(hub: &Hub, meta: &Json, frame: &Json, docs: Vec<(u64, String)>, over: let (meta, frame) = (persona::canon(meta), persona::canon(frame)); let mut b = board(hub); b.name = name; b.glyph = glyph; if over { b.docs.clear(); b.last = 0; } - for (n, d) in docs { b.docs.push_back(d); b.last = n; if b.docs.len() > KEEP { b.docs.pop_front(); } } + // an event already kept (a segment opens at its sealed line, the last event of the file before) is not kept twice + for (n, d) in docs { if n <= b.last { continue; } b.docs.push_back(d); b.last = n; if b.docs.len() > KEEP { b.docs.pop_front(); } } if b.meta != meta || b.layout == 0 { b.meta = meta; b.layout += 1; } b.frame = frame; b.seq += 1; hub.1.notify_all(); @@ -912,11 +913,22 @@ fn add(site: &Site, path: String, over: Labels) { { let mut l = entries(site); if l.iter().any(|e| e.path == path) { return; } let id = id_of(&path, &l); l.push(Arc::new(Entry { id, path: path.clone(), over, hub: hub.clone() })); } std::thread::spawn(move || { let engine = crate::persona_engine(); feed_strand(&path, &hub, &engine); }); } -/// The `*.strand` files in `dir`, by name +/// The `*.strand` files in `dir`, by name; a life's segments (§5.7: `NAME.0001.strand`, `NAME.0002.strand`, ... after +/// `NAME.strand`, without a gap) are one strand, `NAME.strand` fn scan(dir: &str) -> Vec { let mut v: Vec = std::fs::read_dir(dir).into_iter().flatten().flatten().map(|e| e.path()) .filter(|p| p.extension().is_some_and(|x| x == "strand") && p.is_file()).map(|p| p.to_string_lossy().into_owned()).collect(); - v.sort(); v + v.sort(); + let segs: Vec = v.iter().flat_map(|f| (1..).map(|k| crate::live::segment_name(f, k)).take_while(|s| v.contains(s)).collect::>()).collect(); + v.retain(|f| !segs.contains(f)); v +} +/// The newest segment of the strand `base` from index `k` on (§5.7): the next one counts once its header and sealed line are +/// both in (a seal writes them together; a reader can see the header first) +fn newest(base: &str, mut k: u64) -> u64 { + let ready = |f: &str| { let Ok(mut h) = std::fs::File::open(f) else { return false }; let (mut n, mut b) = (0, [0u8; 8192]); + loop { match h.read(&mut b) { Ok(0) | Err(_) => return false, Ok(m) => { n += b[..m].iter().filter(|&&c| c == b'\n').count(); if n >= 2 { return true } } } } }; + while ready(&crate::live::segment_name(base, k + 1)) { k += 1; } + k } /// With --dir, every second: the strands added to it since are served too (a strand removed stays listed, as gone); without, nothing fn watch_dir(site: &Site) -> ! { @@ -931,10 +943,13 @@ fn strands_json(site: &Site) -> String { let rows: Vec = entries(site).iter().map(|e| { let (mut l, mut err) = match labels::read(&e.path) { Ok(l) => (l, None), Err(x) => (Labels::new(), Some(x.replace(&labels::sidecar(&e.path), &name(&labels::sidecar(&e.path))))) }; for (k, v) in &e.over { if let Err(x) = labels::set(&mut l, k.clone(), v.clone()) { err = Some(format!("{k}: {x}")); } } - let age = std::fs::metadata(&e.path).and_then(|m| m.modified()).ok().and_then(|t| now.duration_since(t).ok()).map_or(Json::Null, |d| json::num(d.as_secs() as f64)); + // a life in segments: its newest file (the one written to) + let k = newest(&e.path, 0); + let age = std::fs::metadata(crate::live::segment_name(&e.path, k)).and_then(|m| m.modified()).ok().and_then(|t| now.duration_since(t).ok()).map_or(Json::Null, |d| json::num(d.as_secs() as f64)); let b = board(&e.hub); let opt = |x: &str| if x.is_empty() { Json::Null } else { json::str(x) }; let mut kv = vec![("id", json::str(&e.id)), ("strand", json::str(&name(&e.path))), ("labels", labels::to_json(&l)), ("persona", opt(&b.name)), ("n", json::num(b.last as f64)), ("glyph", opt(&b.glyph)), ("age_s", age)]; if let Some(x) = err { kv.push(("labels_error", json::str(&x))); } + if k > 0 { kv.push(("segment", json::num(k as f64))); } json::obj(kv) }).collect(); persona::canon(&json::obj(vec![("demo", Json::Bool(site.demo.is_some())), ("dir", site.dir.as_deref().map_or(Json::Null, |d| json::str(&name(d)))), ("strands", Json::Arr(rows))])) + "\n" } @@ -964,11 +979,17 @@ fn serve(port: u16, open: bool, site: &Arc, feed: &mut dyn FnMut()) -> ! { feed(); loop { std::thread::park(); } } -/// The strand followed for the page: every 100 ms, what was appended is replayed and published +/// The strand followed for the page: every 100 ms, what was appended is replayed and published. A life in segments (§5.7) is +/// followed in its newest file: when a seal writes the next one, the rest of the sealed file is replayed, then the page goes +/// on in the new file (it opens at its sealed line, the event already shown); the layout and the event count carry on fn feed_strand(path: &str, hub: &Hub, eng: Engine) -> ! { - let mut fl = Followed::new(path); + let mut k = newest(path, 0); + let mut fl = Followed::new(&crate::live::segment_name(path, k)); let mut fresh = true; - loop { step(&mut fl, path, hub, eng, &mut fresh, CATCH_UP); std::thread::sleep(Duration::from_millis(100)); } + loop { step(&mut fl, path, hub, eng, &mut fresh, CATCH_UP); + let n = newest(path, k); + if n != k { step(&mut fl, path, hub, eng, &mut fresh, CATCH_UP); k = n; fl = Followed::new(&crate::live::segment_name(path, k)); continue; } + std::thread::sleep(Duration::from_millis(100)); } } /// One poll of the strand for the page: what was appended is replayed and published. A long replay (a large strand read from /// its header) is also published every `every` on the way, with a note, so the page draws while it catches up. diff --git a/probbit-cli/tests/live.rs b/probbit-cli/tests/live.rs index c714df4..c7bb6d3 100644 --- a/probbit-cli/tests/live.rs +++ b/probbit-cli/tests/live.rs @@ -730,3 +730,30 @@ fn a_segmented_life_equals_one_file_and_verify_chain_walks_back() { let (c, v, e) = probbit(&["live", "verify", &format!("{dir}/x.0002.strand"), "--chain"]); assert_eq!(c, 0, "{e}{v}"); assert_eq!(s(&parse(&v), &["segments"]), &Json::Num(3.0)); let _ = std::fs::remove_dir_all(&dir); clean(&[&all, &a, &b, &st1, &one, &st2, &st3, &xa]); } + +/// `live control STRAND seal` (§5.7): a host seals when it decides. A life of 10 events sealed, 5 more (the run continues the +/// newest segment), sealed again: three files whose chain verifies to the one-file life's final state; a seal right after a seal +/// (the last line a checkpoint) is refused and writes nothing; a seal takes no --by +#[test] +fn a_host_seals_a_strand_and_the_chain_verifies() { + let evs: Vec = (0..15).map(|t| format!(r#"{{"elapsed_hours":1,"praise":{}}}"#, t % 2 == 0)).collect(); + let (all, a, b) = (events_file("seal-all.jsonl", &evs), events_file("seal-a.jsonl", &evs[..10]), events_file("seal-b.jsonl", &evs[10..])); + let (st1, one) = (init(TUTOR, 4, "seal-one.json"), tmp("seal-one.strand")); clean(&[&one]); + let (c, out1, e) = live(TUTOR, &st1, &all, &one, "4"); assert_eq!(c, 0, "{e}"); + let dir = tmp("seal-dir"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); + let base = format!("{dir}/life.strand"); let st2 = init(TUTOR, 4, "seal-two.json"); + let (c, o1, e) = live(TUTOR, &st2, &a, &base, "4"); assert_eq!(c, 0, "{e}"); + let (c, o, e) = probbit(&["live", "control", &base, "seal"]); assert_eq!(c, 0, "{e}{o}"); let v = parse(&o); + assert_eq!((s(&v, &["segment"]), s(&v, &["file"]).as_str()), (&Json::Num(1.0), Some(format!("{dir}/life.0001.strand").as_str()))); + assert_eq!(read(&base).lines().last(), read(&format!("{dir}/life.0001.strand")).lines().nth(1), "the sealed line ends one file and follows the next header"); + let before = read(&format!("{dir}/life.0001.strand")); + let (c, o, e) = probbit(&["live", "control", &base, "seal"]); assert_eq!(c, 2, "{o}"); assert!(e.contains("not an event line"), "{e}"); + assert_eq!(read(&format!("{dir}/life.0001.strand")), before, "nothing written"); assert!(!std::path::Path::new(&format!("{dir}/life.0002.strand")).exists()); + let (c, _, e) = probbit(&["live", "control", &base, "seal", "--by", "human:owner"]); assert_eq!(c, 2, "{e}"); + let (c, o2, e) = live(TUTOR, &st2, &b, &base, "4"); assert_eq!(c, 0, "{e}"); assert_eq!(format!("{o1}{o2}"), out1, "the stances"); + let (c, o, e) = probbit(&["live", "control", &base, "seal"]); assert_eq!(c, 0, "{e}{o}"); assert_eq!(s(&parse(&o), &["segment"]), &Json::Num(2.0)); + let (c, v, e) = probbit(&["live", "verify", &format!("{dir}/life.0002.strand"), "--chain"]); assert_eq!(c, 0, "{e}{v}"); let v = parse(&v); + let (_, vone, _) = probbit(&["live", "verify", &one]); + assert_eq!((s(&v, &["segments"]), s(&v, &["events"]), s(&v, &["final_state"])), (&Json::Num(3.0), &Json::Num(15.0), s(&parse(&vone), &["final_state"]))); + let _ = std::fs::remove_dir_all(&dir); clean(&[&all, &a, &b, &st1, &one, &st2]); +} diff --git a/probbit-cli/tests/monitor.rs b/probbit-cli/tests/monitor.rs index 57a55c4..d118c88 100644 --- a/probbit-cli/tests/monitor.rs +++ b/probbit-cli/tests/monitor.rs @@ -499,3 +499,35 @@ fn checkpointed_strands_of_an_expectation_persona_open() { assert_eq!(c, 0, "{out}{err}"); assert!(top.contains("| event 22 |") && top.ends_with("replay verified from checkpoint 20 [ok]"), "{top}"); let _ = std::fs::remove_file(&tutor); } + +/// §5.7 segments on the page: with `--dir`, a life's `NAME.strand` and `NAME.000k.strand` are one strand, id `NAME`, listed with +/// the life's event count and the base name's sidecar; its stream follows the newest segment and carries on across a seal (the +/// next file is picked up within the 1 s scan) +#[test] +fn a_served_life_in_segments_is_one_strand_that_follows_its_seals() { + let dir = std::env::temp_dir().join(format!("probbit-monitor-segments-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); + let at = |n: &str| dir.join(n).to_string_lossy().into_owned(); + let (base, st, ev) = (at("life.strand"), at("st.json"), at("ev.jsonl")); + let (c, _, e) = probbit(&["persona", "init", "../examples/persona/tutor.yaml", "--seed", "2", "--out", &st]); assert_eq!(c, 0, "{e}"); + let run = |n: usize, extra: &[&str]| { std::fs::write(&ev, (0..n).map(|t| format!("{{\"praise\":{},\"elapsed_hours\":1}}\n", t % 2 == 0)).collect::()).unwrap(); + let (c, o, e) = probbit(&[&["live", "../examples/persona/tutor.yaml", "--state", &st, "--clock", "fixed", "--events", &ev, "--strand", &base][..], extra].concat()); assert_eq!(c, 0, "{e}"); o }; + run(5, &["--segment-every", "3", "--label", "agent=Nova"]); + assert!(std::path::Path::new(&at("life.0001.strand")).exists()); + let (child, port) = served(&["--dir", &dir.to_string_lossy()]); let host = format!("127.0.0.1:{port}"); + let t = Instant::now(); let mut list = String::new(); + while !list.contains(r#""n":5,"#) { assert!(t.elapsed() < Duration::from_secs(10), "listed with n 5: {list}"); std::thread::sleep(Duration::from_millis(100)); list = get(port, "/strands", &host).2; } + assert!(list.contains(r#""id":"life","labels":{"agent":"Nova"}"#) && list.contains(r#""segment":1"#) && !list.contains("life.0001") && list.matches(r#""id":"#).count() == 1, "one strand: {list}"); + let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap(); s.set_read_timeout(Some(Duration::from_secs(20))).unwrap(); + write!(s, "GET /s/life/events HTTP/1.1\r\nHost: {host}\r\n\r\n").unwrap(); + let mut buf = String::new(); let evs = stream(&mut s, &mut buf, 2); assert!(evs[1].1.contains(r#""n":5,"#), "{}", evs[1].1); + // a host seals; the life goes on in the next file + let (c, o, e) = probbit(&["live", "control", &base, "seal"]); assert_eq!(c, 0, "{e}{o}"); assert!(o.contains("life.0002.strand"), "{o}"); + let docs = run(2, &[]); let six = docs.lines().next().unwrap().to_string(); + let (t, mut count) = (Instant::now(), 3); + loop { let evs = stream(&mut s, &mut buf, count); if evs.iter().any(|(_, d)| d.contains(r#""n":7,"#)) { break; } + assert!(t.elapsed() < Duration::from_secs(5), "event 7 streamed after the seal: {buf}"); count = evs.len() + 1; } + assert!(!buf.contains(r#""diverges":{"#), "{buf}"); + assert_eq!(get(port, "/s/life/doc/6", &host).2, format!("{six}\n")); + let list = get(port, "/strands", &host).2; assert!(list.contains(r#""n":7,"#) && list.contains(r#""segment":2"#) && list.matches(r#""id":"#).count() == 1, "{list}"); + drop(s); drop(child); let _ = std::fs::remove_dir_all(&dir); +} From 1c93279c94a842db557cc5774dd028534802c98e Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 05:51:00 -0700 Subject: [PATCH 16/61] docs: the engine string a firmware writes and what an older engine says (exit 5 at the first line it does not know, e.g. a checkpoint's expect; a segment file after the first reads as a divergence at line 2 before 0.14.0); tests: the served demo's restart gets 45 s for its paced loop (a host whose sleeps overshoot 2.5x failed at 20 s; the replay is about 30 ms); CHANGELOG --- CHANGELOG.md | 2 ++ docs/embedding.md | 16 ++++++++++++++++ docs/persona.md | 4 +++- probbit-cli/tests/monitor.rs | 7 +++++-- 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f6e904..58d9199 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,8 @@ All notable changes to this project are documented here. The format follows ### Fixed +- The test of the served demo's restart allows 45 s for its paced loop (about 13 s of sleeps): on a virtual machine whose sleeps + overshoot up to 2.5 times it failed at 20 s, on this tree and the one before (the replay itself takes about 30 ms). - `probbit monitor` opens a strand with checkpoint lines when its persona has an `expectation` block: it replays such a strand from the header (`--once`, `--follow`, `--serve`, and each strand of `--dir` by its own persona) instead of refusing it at the last checkpoint, which does not carry the expectation's memory. Other personas still start at the last diff --git a/docs/embedding.md b/docs/embedding.md index 4109d26..5d9f66d 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -178,6 +178,22 @@ digits for the deviation receipt), so the device's strand and the host's replay includes the stance's `deviation` on checkpoint lines (`strand::DEV_R6_SINCE`). A strand whose header names an earlier engine keeps its full-precision deviation, and verify accepts it when it equals the replay at 6 decimals. +**The engine string.** A strand's header names the engine that wrote it, and the rules a replay applies follow that name, not +the replaying engine: from `probbit 0.14.0` on (a `-dev` or other suffix ignored: `probbit 0.14.0-dev` is 0.14.0) a strand gets +the rounded deviation, the canonical fold of the expectation's memory and `expect` on checkpoints; an earlier name keeps the +earlier rules, byte for byte. A firmware passes its engine string to `Live::start` (the header's `engine`) and should pass the +version of the crate it builds with (`strand::engine()` gives `probbit ` + that version; a development build says `probbit 0.14.0-dev`), +so a strand it writes replays on the host under the same rules. A strand that keeps naming an older engine keeps the older +fold, and its boot folds the expectation over every line (no checkpoint carries the memory). + +An engine older than the strand verifies what it can read: it does not compare engine names. At the first line with a field it +does not know it stops with `unsupported by this engine: FIELD` (exit 5: a newer engine's strand, not verified, and not a +divergence), so 0.13.0 answers exit 5 at the first checkpoint that carries `expect`. A 0.14.0 strand without such a line (no +expectation block, or no checkpoint yet) has nothing 0.13.0 does not know, and 0.13.0 replays it under its own rules (ok while +those give the same bytes). A segment file after the first (`NAME.0001.strand`, ...) is not readable before 0.14.0: its +second line chains into the previous file, and 0.13.0 reports a divergence at line 2 (exit 1) before it reaches the header's +`segment` key. Verify strands of 0.14.0 with 0.14.0 or later. + ### Sizing a strand on flash: checkpoints and segments A strand grows by one event line per event and one checkpoint line every K events (`live.checkpoint`), and it never shrinks. diff --git a/docs/persona.md b/docs/persona.md index 87ac6ed..392f5f8 100644 --- a/docs/persona.md +++ b/docs/persona.md @@ -1571,7 +1571,9 @@ sealed state, and its second line is the sealing line itself, byte for byte. The lines of all segments, each segment's header and repeated sealing line set aside, are the strand the same life writes to one file with a checkpoint at each seal. The labels sidecar is the first file's (`NAME.labels.json`); a run on `--strand NAME.strand` continues the newest segment that exists (a sealed file is never appended to). Before 0.14.0 a persona with an -expectation block is not segmented (its checkpoint does not carry the memory). +expectation block is not segmented (its checkpoint does not carry the memory). An engine before 0.14.0 cannot read a segment +file after the first: its second line chains into the previous file, so such an engine reports a divergence at line 2 (exit +1) instead of the unknown `segment` key. A host that decides when to seal runs `probbit live control NAME.strand seal` (under the writer lock, like any control): it replays the newest segment (from its last checkpoint), refuses unless that file's last line is an event line (a seal follows diff --git a/probbit-cli/tests/monitor.rs b/probbit-cli/tests/monitor.rs index d118c88..2c414cc 100644 --- a/probbit-cli/tests/monitor.rs +++ b/probbit-cli/tests/monitor.rs @@ -351,7 +351,10 @@ fn drives_demo_draws_goal_state_and_rejects_unknown_scenarios() { } } -/// The page makes the reset explicit: a completion cue, then loop 2 with a fresh event counter. +/// The page makes the reset explicit: a completion cue, then loop 2 with a fresh event counter. The loop is paced by sleeps (8 s +/// of events, then the 5 s hold: loop 2 about 13 s after the stream opens); the budget is 45 s because a host whose sleeps +/// overshoot (a virtual machine measured 8 x sleep(1) at 8.9 to 19.8 s) stretches that pacing, not the work (the loop's +/// replay itself is about 30 ms) #[test] fn served_demo_labels_its_restart() { let mut child = Reap(Command::new(env!("CARGO_BIN_EXE_probbit")).args(["monitor", "--demo", "drives", "--serve"]) @@ -362,7 +365,7 @@ fn served_demo_labels_its_restart() { write!(s, "GET /events HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n").unwrap(); let mut buf = String::new(); let mut count = 2; let started = Instant::now(); loop { - assert!(started.elapsed() < Duration::from_secs(20), "the demo did not label its restart"); + assert!(started.elapsed() < Duration::from_secs(45), "the demo did not label its restart"); let evs = stream(&mut s, &mut buf, count); if evs.iter().any(|(_, d)| d.contains("loop 2")) { break; } count = evs.len() + 1; From f8efa4d983de2ae27e5e388f860432c52cb08cb4 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 06:45:48 -0700 Subject: [PATCH 17/61] probbit-persona: the expectation's cache: the members' exact marginals by program (sha256 of the program's structure without its comment, -0 as 0, as its text), so the deviation solves only the programs it has not solved; only the rows an event used are kept (at most max_combos + 1); a life whose programs move every event backs off (the cache unconsulted for 1, 2, ... 32 events after one without a hit); Live::expect_cache (on by default; off gives the same bytes); turn_trace counts expect_cached and expect_solved; test: lives of the expectation fixture persona (max_combos 16 and 64, engines 0.13.0 and 0.14.0) give every stance document, strand byte and final state equal with the cache on and off --- probbit-cli/src/live.rs | 23 ++++++++++++ probbit-persona/src/persona.rs | 67 +++++++++++++++++++++++++++++++++- probbit-persona/src/strand.rs | 10 +++-- probbit-persona/src/trace.rs | 17 ++++++--- 4 files changed, 107 insertions(+), 10 deletions(-) diff --git a/probbit-cli/src/live.rs b/probbit-cli/src/live.rs index 23a1c3d..ad017fe 100644 --- a/probbit-cli/src/live.rs +++ b/probbit-cli/src/live.rs @@ -473,6 +473,29 @@ mod tests { assert_eq!(verify(&(l3.join("\n") + "\n"), &run).unwrap_err(), (2, SEALED.to_string())); } + /// The expectation's cache moves no byte: lives of the expectation fixture persona (max_combos 16 and 64, engines 0.13.0 and + /// 0.14.0; 500 events of many combos, then one event 200 times, a checkpoint every 50) run with the cache on and off: + /// every stance document, every strand line and the final state are the same, the cache answers programs (hits > 0) and + /// never holds more than max_combos + 1 rows; the life verifies + #[test] + fn the_expectation_cache_moves_no_byte() { + let y = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/monitor/tutor-expect.yaml")).unwrap(); + let doc0 = probbit_persona::yaml::load(&y).unwrap_or_else(|e| panic!("{}", e.msg)); + let ev = |t: usize| s1_event(if t < 500 { t } else { 1 }); + for mc in [16usize, 64] { for engine in ["probbit 0.13.0", "probbit 0.14.0"] { + let mut d = doc0.clone(); put(&mut d, &["expectation"], json::parse(&format!(r#"{{"max_combos":{mc}}}"#)).unwrap()); let p = persona::build(&d).unwrap(); + let life = |on: bool| { let (mut lv, head) = Live::start(p.clone(), &d, persona::init(&p, Some(2), true, &run), Clock::Fixed, engine); + lv.expect_cache = persona::ExpectCache::new(on); assert_eq!(lv.expect_cache.is_on(), on); + let (mut text, mut docs, mut hits, mut rows) = (format!("{head}\n"), vec![], 0u32, 0usize); + for t in 0..700 { let (s, l) = lv.event(&ev(t), &run).unwrap(); text += &l; text.push('\n'); docs.push(persona::canon(&s)); + hits += lv.expect_cache.last().0; rows = rows.max(lv.expect_cache.len()); if lv.n % 50 == 0 { text += &lv.checkpoint(&s); text.push('\n'); } } + (text, docs, lv.st.digest.clone(), hits, rows) }; + let (on, off) = (life(true), life(false)); + assert_eq!(on.1, off.1, "every stance document ({mc}, {engine})"); assert_eq!(on.0, off.0, "every strand byte ({mc}, {engine})"); assert_eq!(on.2, off.2); + assert!(on.3 > 0 && off.3 == 0, "hits on {} off {}", on.3, off.3); assert!(on.4 <= mc + 1, "rows {}", on.4); + assert_eq!(verify(&on.0, &run).unwrap().get("events"), Some(&Json::Num(700.0))); } } + } + /// S1 measurements (ignored: run with --ignored --nocapture): the expectation fixture persona, 500 events, a checkpoint every /// 50: checkpoint line bytes (max_combos 16 and 64), and resume's time at 166 and 500 events, 0.13.0 (fold from the header) /// against 0.14.0 (fold from the last checkpoint), with the full replay for scale diff --git a/probbit-persona/src/persona.rs b/probbit-persona/src/persona.rs index 3f824cf..7be9c9d 100644 --- a/probbit-persona/src/persona.rs +++ b/probbit-persona/src/persona.rs @@ -4006,6 +4006,63 @@ fn exact_marg(p: &Persona, st: &State, prog: &Json, eng: Engine) -> Option Some(doc.get("marginals")?.as_obj()?.iter().filter(|(k, _)| !k.starts_with("free.")).map(|(k, v)| (k.clone(), v.as_obj().map_or(vec![], |o| { let mut r: Vec<(String, f64)> = o.iter().map(|(l, x)| (l.clone(), x.as_f64().unwrap_or(0.0))).collect(); r.sort_by(|a, b| a.0.cmp(&b.0)); r }))).collect()) } +/// The expectation's cache: per program the deviation solved, its non-twin exact marginals, keyed by the sha256 of the program +/// without its `comment` (the only key that names the turn and the seed). The exact tier's marginals are a function of the +/// program's model alone (the seed, the sweeps and the chains are the sampler's), so a hit is the value the solve would return, +/// bit for bit: every `pred`, `self`, `z`, stance and strand byte is the same with the cache on or off. The members' programs +/// are rebuilt every event (the state moves every event: its digest carries the turn), only the solves are skipped; decay +/// alone never invalidates a row (marginals do not read counts). After each event only the rows that event used are kept: at +/// most `max_combos + 1` (the event's own program, the empty program, the other members). The key is the sha256 of the program's +/// structure (each number by its bits, -0 as 0 as the text writes it, each string with its length): two programs share a row +/// only when their texts are equal. A life whose programs move every event (a state that moves every event) gets no hit: after an event without +/// one the cache is not consulted for the next 1, 2, 4, ... 32 events (a hit resets it; the rows are dropped meanwhile and the event +/// after the pause fills them for the one after it to compare), so such a life keys two events in 34. +#[derive(Clone, Debug)] +pub struct ExpectCache { on: bool, rows: Vec<([u8; 32], Marg)>, used: Vec, hits: u32, solves: u32, skip: u32, back: u32, live: bool, had: bool } +impl Default for ExpectCache { fn default() -> ExpectCache { ExpectCache::new(true) } } +impl ExpectCache { + /// An empty cache; `on: false` solves every program (the receipt is the same) + pub fn new(on: bool) -> ExpectCache { ExpectCache { on, rows: vec![], used: vec![], hits: 0, solves: 0, skip: 0, back: 0, live: false, had: false } } + /// Whether the cache is used + pub fn is_on(&self) -> bool { self.on } + /// The last event's programs answered from the cache and solved + pub fn last(&self) -> (u32, u32) { (self.hits, self.solves) } + /// The rows held (at most the persona's `max_combos + 1`) + pub fn len(&self) -> usize { self.rows.len() } + /// Whether no row is held + pub fn is_empty(&self) -> bool { self.rows.is_empty() } + /// The heap bytes the rows hold on this target (each row: its key, per variable its id and per level its name and value) + pub fn bytes(&self) -> usize { + use core::mem::size_of; + self.rows.capacity() * size_of::<([u8; 32], Marg)>() + self.used.capacity() + self.rows.iter().map(|(_, m)| m.capacity() * size_of::<(String, Vec<(String, f64)>)>() + + m.iter().map(|(id, ls)| id.capacity() + ls.capacity() * size_of::<(String, f64)>() + ls.iter().map(|(l, _)| l.capacity()).sum::()).sum::()).sum::() + } + fn begin(&mut self) { self.hits = 0; self.solves = 0; self.used.clear(); self.used.resize(self.rows.len(), false); + self.live = self.on && self.skip == 0; self.had = !self.rows.is_empty(); if self.skip > 0 { self.skip -= 1; } } + fn end(&mut self) { + // an event that had rows to compare and found none backs off; one that started empty only fills (the next one compares) + if self.live { if self.hits > 0 { self.back = 0; } else if self.had { self.back = (self.back * 2).clamp(1, 32); self.skip = self.back; } } + let mut i = 0; self.rows.retain(|_| { i += 1; self.used[i - 1] }); self.used.clear(); } + /// The program's exact marginals: from the cache when the program (without its comment) was solved before, else solved + fn marg(&mut self, p: &Persona, st: &State, prog: &Json, eng: Engine) -> Option { + if !self.live { self.solves += 1; return exact_marg(p, st, prog, eng); } + let mut h = Sha256::new(); + match prog { Json::Obj(v) => for (k, x) in v.iter().filter(|(k, _)| k != "comment") { hash_str(&mut h, k); hash_json(&mut h, x); }, x => hash_json(&mut h, x) } + let key = h.finish(); + if let Some(i) = self.rows.iter().position(|r| r.0 == key) { self.used[i] = true; self.hits += 1; return Some(self.rows[i].1.clone()); } + self.solves += 1; let m = exact_marg(p, st, prog, eng)?; + self.rows.push((key, m.clone())); self.used.push(true); Some(m) + } +} +fn hash_str(h: &mut Sha256, s: &str) { h.update(&(s.len() as u64).to_le_bytes()); h.update(s.as_bytes()); } +/// A value's structure into `h`: a tag per kind, numbers by their bits (-0 as 0, as the program's text writes both), strings and +/// lists with their lengths +fn hash_json(h: &mut Sha256, j: &Json) { + match j { Json::Null => h.update(b"n"), Json::Bool(b) => h.update(if *b { b"t" } else { b"f" }), Json::Num(x) => { h.update(b"#"); h.update(&(if *x == 0.0 { 0 } else { x.to_bits() }).to_le_bytes()) } + Json::Str(s) => { h.update(b"s"); hash_str(h, s) } + Json::Arr(v) => { h.update(b"["); h.update(&(v.len() as u64).to_le_bytes()); for x in v { hash_json(h, x); } } + Json::Obj(v) => { h.update(b"{"); h.update(&(v.len() as u64).to_le_bytes()); for (k, x) in v { hash_str(h, k); hash_json(h, x); } } } +} fn with_elapsed(rep: &Json, e: &Json) -> Json { let mut kv: Vec<(String, Json)> = rep.as_obj().map_or(vec![], |o| o.to_vec()); kv.push(("elapsed_hours".into(), e.clone())); Json::Obj(kv) } @@ -4023,10 +4080,16 @@ fn kl_marg(a: &Marg, b: &Marg) -> Option<(f64, Vec)> { /// whenever its combo is). Mixture marginals are linear in the members' exact marginals, so every number is exact from the /// M + 1 solves. None without the block, or when a program is not solved exactly or a divergence is not finite. #[inline(never)] -pub fn deviation(p: &Persona, st: &State, raw: &Json, mem: &ExpectMem, eng: Engine) -> Option { +pub fn deviation(p: &Persona, st: &State, raw: &Json, mem: &ExpectMem, eng: Engine) -> Option { deviation_cached(p, st, raw, mem, eng, &mut ExpectCache::new(false)) } +/// `deviation` with the members' marginals from `cache` (`ExpectCache`): the same value, fewer solves +#[inline(never)] +pub fn deviation_cached(p: &Persona, st: &State, raw: &Json, mem: &ExpectMem, eng: Engine, cache: &mut ExpectCache) -> Option { + cache.begin(); let d = deviation_in(p, st, raw, mem, eng, cache); cache.end(); crate::trace::expect_cache(cache.hits, cache.solves); d +} +fn deviation_in(p: &Persona, st: &State, raw: &Json, mem: &ExpectMem, eng: Engine, cache: &mut ExpectCache) -> Option { let ex = p.expect.as_ref()?; let kv = raw.as_obj()?; let e = get(kv, "elapsed_hours").cloned().unwrap_or(Json::Num(0.0)); let key = combo_key(p, kv); - let solve = |ins: &Json| program_json(p, st, ins).ok().and_then(|prog| exact_marg(p, st, &prog, eng)); + let mut solve = |ins: &Json| program_json(p, st, ins).ok().and_then(|prog| cache.marg(p, st, &prog, eng)); let a = solve(raw)?; let ed = solve(&with_elapsed(&Json::Obj(vec![]), &e))?; let ws = expect_weights(ex, mem); let member = ws.iter().any(|(k, _)| *k == key); let cold = !mem.warm && key == "{}"; diff --git a/probbit-persona/src/strand.rs b/probbit-persona/src/strand.rs index 8d08a8f..8321b55 100644 --- a/probbit-persona/src/strand.rs +++ b/probbit-persona/src/strand.rs @@ -54,7 +54,9 @@ pub struct Live { pub p: Persona, pub st: State, clock: Clock, last: f64, prev: /// RFC 0009: the expectation's memory (a fold of the logged events) and the last event's exact trajectory (derived, never logged) pub expect: Option, pub traj: Option, /// The strand's engine is before 0.14.0 (DEV_R6_SINCE): the stance's `deviation` keeps full precision, as that engine wrote it - pub dev_full: bool } + pub dev_full: bool, + /// RFC 0009: the members' exact marginals by program (`persona::ExpectCache`, on by default; off gives the same bytes) + pub expect_cache: persona::ExpectCache } /// The engine from which the stance's `deviation` (on checkpoint lines) is rounded to 6 decimals: a transcendental's last bits /// differ between platforms' libm (macOS, glibc, the `libm` crate of a `nostd` build), and 6 decimals make the line's bytes, /// and so the chain, the same on all of them @@ -113,7 +115,7 @@ impl Live { fn boxed(p: Box, st: Box, clock: Clock, prev: String, n: u64, expect: Option, dev_full: bool) -> Box { use core::mem::MaybeUninit; use core::ptr::{addr_of_mut, copy_nonoverlapping}; // every field is written below: this pattern stops compiling when a field is added and not written - let _all = |l: &Live| { let Live { p: _, st: _, clock: _, last: _, prev: _, n: _, status: _, controls: _, checkpoints: _, rests: _, expect: _, traj: _, dev_full: _ } = l; }; + let _all = |l: &Live| { let Live { p: _, st: _, clock: _, last: _, prev: _, n: _, status: _, controls: _, checkpoints: _, rests: _, expect: _, traj: _, dev_full: _, expect_cache: _ } = l; }; let mut b: Box> = Box::new(MaybeUninit::uninit()); let l = b.as_mut_ptr(); // SAFETY: `l` points to an allocation for one `Live`; each field is written once, through raw field pointers (no reference @@ -126,6 +128,7 @@ impl Live { addr_of_mut!((*l).clock).write(clock); addr_of_mut!((*l).last).write(0.0); addr_of_mut!((*l).prev).write(prev); addr_of_mut!((*l).n).write(n); addr_of_mut!((*l).status).write(Status::Active); addr_of_mut!((*l).controls).write(0); addr_of_mut!((*l).checkpoints).write(0); addr_of_mut!((*l).rests).write(0); addr_of_mut!((*l).expect).write(expect); addr_of_mut!((*l).traj).write(None); addr_of_mut!((*l).dev_full).write(dev_full); + addr_of_mut!((*l).expect_cache).write(persona::ExpectCache::new(true)); Box::from_raw(Box::into_raw(b) as *mut Live) } } @@ -146,7 +149,8 @@ impl Live { let inputs = Json::Obj(kv); enter(Phase::Expect); // RFC 0009: the deviation and the trajectory are read from the state before the event (effects {}: the turn is as without them) - let dev = self.expect.as_ref().and_then(|m| persona::deviation(&self.p, &self.st, &inputs, m, eng)); + let cache = &mut self.expect_cache; + let dev = self.expect.as_ref().and_then(|m| persona::deviation_cached(&self.p, &self.st, &inputs, m, eng, cache)); let traj = persona::trajectory(&self.p, &self.st, &inputs); let (mut stance, ns) = persona::turn_boxed(&self.p, &self.st, &inputs, false, eng, false)?; let sd = persona::stance_sha(&stance); diff --git a/probbit-persona/src/trace.rs b/probbit-persona/src/trace.rs index 0ceea0e..62e5137 100644 --- a/probbit-persona/src/trace.rs +++ b/probbit-persona/src/trace.rs @@ -37,9 +37,10 @@ pub const PHASES: usize = 9; pub const NAMES: [&str; PHASES] = ["input", "expect", "prep", "compile", "solve", "decode", "document", "line", "fold"]; /// One event's breakdown: per phase (indexed by `Phase as usize`) the microseconds and the largest probe value; the event's -/// total microseconds and the engine calls of the turn +/// total microseconds and the engine calls of the turn; RFC 0009: the expectation's programs answered from its cache +/// (`expect_cached`) and solved (`expect_solved`) #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct TurnTrace { pub us: [u64; PHASES], pub bytes: [u64; PHASES], pub total_us: u64, pub solves: u32 } +pub struct TurnTrace { pub us: [u64; PHASES], pub bytes: [u64; PHASES], pub total_us: u64, pub solves: u32, pub expect_cached: u32, pub expect_solved: u32 } #[cfg(feature = "turn_trace")] mod on { @@ -65,6 +66,7 @@ mod on { let mut g = t.borrow_mut(); if let Some(b) = b { let x = &mut g.acc.bytes[prev as usize]; *x = (*x).max(b); } g.open = Some((t0, us(t0))); g.cur = ph; prev }) } + pub(crate) fn expect_cache(hits: u32, solves: u32) { T.with(|t| { let mut g = t.borrow_mut(); if g.open.is_some() { g.acc.expect_cached += hits; g.acc.expect_solved += solves; } }) } pub(crate) fn end() { let cur = T.with(|t| t.borrow().cur); enter(cur); T.with(|t| { let mut g = t.borrow_mut(); if let Some((t0, _)) = g.open.take() { g.acc.total_us = us(t0); g.last = g.acc; } }) @@ -73,7 +75,7 @@ mod on { #[cfg(feature = "turn_trace")] pub use on::{last, set_probe}; #[cfg(feature = "turn_trace")] -pub(crate) use on::{begin, end, enter}; +pub(crate) use on::{begin, end, enter, expect_cache}; #[cfg(not(feature = "turn_trace"))] #[inline(always)] pub(crate) fn begin() {} @@ -83,12 +85,17 @@ pub(crate) fn enter(ph: Phase) -> Phase { ph } #[cfg(not(feature = "turn_trace"))] #[inline(always)] pub(crate) fn end() {} +#[cfg(not(feature = "turn_trace"))] +#[inline(always)] +pub(crate) fn expect_cache(_hits: u32, _solves: u32) {} impl TurnTrace { - /// As JSON: `{"us": {phase: µs}, "bytes": {phase: value}, "total_us": n, "solves": n}` (phases in `Phase` order) + /// As JSON: `{"us": {phase: µs}, "bytes": {phase: value}, "total_us": n, "solves": n, "expect_cached": n, "expect_solved": n}` + /// (phases in `Phase` order) pub fn to_json(&self) -> crate::json::Json { use crate::json::Json; let row = |v: &[u64; PHASES]| Json::Obj(NAMES.iter().zip(v).map(|(k, x)| (k.to_string(), Json::Num(*x as f64))).collect()); - Json::Obj(vec![("us".into(), row(&self.us)), ("bytes".into(), row(&self.bytes)), ("total_us".into(), Json::Num(self.total_us as f64)), ("solves".into(), Json::Num(self.solves as f64))]) + Json::Obj(vec![("us".into(), row(&self.us)), ("bytes".into(), row(&self.bytes)), ("total_us".into(), Json::Num(self.total_us as f64)), ("solves".into(), Json::Num(self.solves as f64)), + ("expect_cached".into(), Json::Num(self.expect_cached as f64)), ("expect_solved".into(), Json::Num(self.expect_solved as f64))]) } } From 24858a201af518c4ba4cb7ff33f242e28d648e7b Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 07:04:43 -0700 Subject: [PATCH 18/61] probbit-persona: the cache's key is hashed in its own frame (prog_key; the lookup out of line), so the frame that calls the engine holds no hasher; docs: the embedding guide's "The expectation's cache" (the key, what invalidates a row, the back-off, the bytes, the proxy's numbers); CHANGELOG --- CHANGELOG.md | 7 +++++++ docs/embedding.md | 25 +++++++++++++++++++++++++ probbit-persona/src/persona.rs | 16 ++++++++++++---- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58d9199..580c757 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project are documented here. The format follows ## Unreleased +- **The expectation's cache** (docs/embedding.md "The expectation's cache"): `Live` keeps the exact marginals of the + programs the deviation solved, keyed by the program without its comment, so an event whose members' programs are the + previous event's solves none of them again (a life of one repeated event: `expect` 52% → 37% of the turn on the proxy, 95% + of its programs from the cache). Every stance, strand line and digest is the same with the cache on or off + (`Live::expect_cache`, `persona::ExpectCache::new(false)`); at most `max_combos + 1` rows; a life whose programs move every + event (most lives: the state moves) backs off and costs about what it cost before. `turn_trace` gains `expect_cached` and + `expect_solved`; `persona::deviation_cached`. - Development build: the workspace version is `0.14.0-dev` (`probbit --version`, the `engine` of every strand header this build writes, `verified_by`), so the strands it writes get the 0.14.0 behaviours below (the rounded deviation, the canonical fold, `expect` on checkpoints, segments of a persona with an expectation block). Strands of 0.13.0 and earlier diff --git a/docs/embedding.md b/docs/embedding.md index 5d9f66d..3e1f047 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -137,6 +137,31 @@ on the proxy: expect 15,520 / 66,879, prep 14,880 / 20,221, decode 12,464 / 76,5 73,957, document 6,192 / 60,295, fold 4,592 / 35,154, line 4,088 / 35,745, input 3,424 / 1,005 (with the feature compiled in, which adds about 1.7 KB to the event's stack: 15,520 against 13,808 without it). +### The expectation's cache + +`expect` (RFC 0009, a persona with an expectation block) solves, every event, the event's own program, the empty program and +one program per remembered combination (exact tier each). `Live` keeps the non-twin exact marginals of each program it solved +(`Live::expect_cache`, a `persona::ExpectCache`, on by default), keyed by the sha256 of the program's structure without its +`comment` (the only line that names the turn and the seed; each number by its bits, -0 as 0, as the program's text writes +it). The exact tier's marginals are a function of the program's model alone, so a row is the value the solve would return: +the stance, its `deviation`, every strand line and digest are the same with the cache on or off (`ExpectCache::new(false)`). +After each event only the rows that event used are kept: at most `max_combos + 1`. Decay never invalidates a row (marginals +do not read counts); a row stops being used when its program changes, which happens whenever the state the program is built +from moves (moods, needs, senses: every event of most lives; the state's digest itself carries the turn, so it is never a +key). The members' programs are still built every event: the cache skips solves, not compiles. A life whose programs move +every event gets no hit; after an event without one the cache is not consulted for the next 1, 2, 4, ... 32 events (a hit +resets the pause), so such a life keys two events in 34. `turn_trace` counts `expect_cached` and `expect_solved` per event. + +On the proxy (median of 3–7 runs, cache off → on, `expect`'s share of the turn): a life of one event repeated 500 times +(the expectation fixture persona, max_combos 16): 52.2% → 37.4%, 95.2% of programs from the cache; 500 events of many +combos: 80.9% → 81.2% (max_combos 16) and 93.6% → 93.7% (64), no hit; the body persona's recorded day (134 events, max_combos +6): 74.2% → 74.5%, 0.7% of programs from the cache. The rows' heap on the proxy (x86_64): 13,771 B at most for the body +persona (7 rows), 46,471 B (17 rows) and 178,327 B (65 rows) for the fixture persona at max_combos 16 and 64; a 32-bit target +holds about two thirds of that (its `String` and `Vec` headers are 12 bytes, not 24). `ExpectCache::bytes` reports it on the +target. A host short of heap at a large `max_combos` turns the cache off; the bytes do not move. With the cache the body +persona's turn on the proxy takes 13,840 B of stack (13,808 before) and its heap peak is 89,990 B (88,153 before: the rows +the individual holds between events). + Silicon numbers (the same calls on the target's engine thread) are measured by the firmware and will follow; the x86_64 proxy and an Xtensa build differ in frame layout, so read the proxy as the shape and the ratio, not the target's bytes. diff --git a/probbit-persona/src/persona.rs b/probbit-persona/src/persona.rs index 7be9c9d..27e13c1 100644 --- a/probbit-persona/src/persona.rs +++ b/probbit-persona/src/persona.rs @@ -4043,17 +4043,24 @@ impl ExpectCache { // an event that had rows to compare and found none backs off; one that started empty only fills (the next one compares) if self.live { if self.hits > 0 { self.back = 0; } else if self.had { self.back = (self.back * 2).clamp(1, 32); self.skip = self.back; } } let mut i = 0; self.rows.retain(|_| { i += 1; self.used[i - 1] }); self.used.clear(); } - /// The program's exact marginals: from the cache when the program (without its comment) was solved before, else solved + /// The program's exact marginals: from the cache when the program (without its comment) was solved before, else solved (the + /// key's hasher lives in its own frame, not in the one that calls the engine) + #[inline(never)] fn marg(&mut self, p: &Persona, st: &State, prog: &Json, eng: Engine) -> Option { if !self.live { self.solves += 1; return exact_marg(p, st, prog, eng); } - let mut h = Sha256::new(); - match prog { Json::Obj(v) => for (k, x) in v.iter().filter(|(k, _)| k != "comment") { hash_str(&mut h, k); hash_json(&mut h, x); }, x => hash_json(&mut h, x) } - let key = h.finish(); + let key = prog_key(prog); if let Some(i) = self.rows.iter().position(|r| r.0 == key) { self.used[i] = true; self.hits += 1; return Some(self.rows[i].1.clone()); } self.solves += 1; let m = exact_marg(p, st, prog, eng)?; self.rows.push((key, m.clone())); self.used.push(true); Some(m) } } +/// The cache's key of a program: the sha256 of its structure without `comment` +#[inline(never)] +fn prog_key(prog: &Json) -> [u8; 32] { + let mut h = Sha256::new(); + match prog { Json::Obj(v) => for (k, x) in v.iter().filter(|(k, _)| k != "comment") { hash_str(&mut h, k); hash_json(&mut h, x); }, x => hash_json(&mut h, x) } + h.finish() +} fn hash_str(h: &mut Sha256, s: &str) { h.update(&(s.len() as u64).to_le_bytes()); h.update(s.as_bytes()); } /// A value's structure into `h`: a tag per kind, numbers by their bits (-0 as 0, as the program's text writes both), strings and /// lists with their lengths @@ -4086,6 +4093,7 @@ pub fn deviation(p: &Persona, st: &State, raw: &Json, mem: &ExpectMem, eng: Engi pub fn deviation_cached(p: &Persona, st: &State, raw: &Json, mem: &ExpectMem, eng: Engine, cache: &mut ExpectCache) -> Option { cache.begin(); let d = deviation_in(p, st, raw, mem, eng, cache); cache.end(); crate::trace::expect_cache(cache.hits, cache.solves); d } +#[inline(always)] fn deviation_in(p: &Persona, st: &State, raw: &Json, mem: &ExpectMem, eng: Engine, cache: &mut ExpectCache) -> Option { let ex = p.expect.as_ref()?; let kv = raw.as_obj()?; let e = get(kv, "elapsed_hours").cloned().unwrap_or(Json::Num(0.0)); let key = combo_key(p, kv); From f8f47309f5c3351fcd29e2481321278e4a747bd1 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 08:03:00 -0700 Subject: [PATCH 19/61] probbit-persona: the boxed replay: Replay::open_boxed checks the header as the chain check does (the persona built on the heap, the state read into a box, the header written again in pieces against the line, never whole) and builds the individual in its box; Replay holds its Live boxed, so no frame of open or step holds a Live, a Persona or the header text; Replay::open is open_boxed moved out of its box (same checks, same order, same messages, same bytes) --- probbit-persona/src/strand.rs | 46 +++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/probbit-persona/src/strand.rs b/probbit-persona/src/strand.rs index 8321b55..5b7372c 100644 --- a/probbit-persona/src/strand.rs +++ b/probbit-persona/src/strand.rs @@ -322,7 +322,7 @@ pub fn utc_now() -> String { /// rules. `open` checks the header and rebuilds the individual; each `step` replays one event line on the fixed clock and /// checks it (`prev`, `n`, the stance and state digests, the bytes). After an error the replay is spent: the chain past the /// line that differs cannot be checked. -pub struct Replay { pub live: Live, pub header: Json, pub doc: Json, pub line: usize, pub from: Option, last: Option, checkpoint_ready: bool, norms: Normals, at_cap: Vec, seen: (f64, Vec), viab: Option<(f64, Option)>, rest: Option } +pub struct Replay { pub live: Box, pub header: Json, pub doc: Json, pub line: usize, pub from: Option, last: Option, checkpoint_ready: bool, norms: Normals, at_cap: Vec, seen: (f64, Vec), viab: Option<(f64, Option)>, rest: Option } /// A senses block's normals over the replay (§2.11): per sense the means after the last 50 events, the largest move of a mean /// within 50 events so far, and the latest event's move (the monitor's alert: over drift_cap / 2) #[derive(Default)] @@ -341,23 +341,27 @@ impl Normals { impl Replay { /// The header line (without its line ending) -> the replay, ready for event lines; Err((1, what differs)), or Err((1, the /// unsupported `why`)) for a header key, persona field or state field this engine does not know - pub fn open(head: &str) -> Result { - let h = json::parse(head).map_err(|e| (1, format!("the header is not JSON: {}", e.msg)))?; - if h.get("probbit_strand").and_then(Json::as_f64) != Some(FORMAT) { return Err((1, "not a probbit strand (format 1)".into())); } - if let Some((k, _)) = h.as_obj().and_then(|kv| kv.iter().find(|(k, _)| !HEADER.contains(&k.as_str()) && k != "segment")) { return Err((1, unsupported(k))); } - if let Some(sg) = h.get("segment") { check_segment(sg).map_err(|e| (1, format!("the header's segment: {e}")))?; } - let doc = h.get("document").cloned().ok_or((1, "the header has no persona document".to_string()))?; - let p = persona::build(&doc).map_err(|e| (1, read_err(e, "the persona document: ")))?; - if h.get("persona").and_then(|x| x.get("digest")).and_then(Json::as_str) != Some(p.digest.as_str()) { return Err((1, "the persona digest does not match the document".into())); } - let st0 = State::read(&p, h.get("state").unwrap_or(&Json::Null)).map_err(|e| (1, read_err(e, "the initial state: ")))?; - let (live, header) = Live::start(p, &doc, st0, Clock::Fixed, h.get("engine").and_then(Json::as_str).unwrap_or("")); - let header = match h.get("segment") { None => header, Some(sg) => { let mut b = Vec::new(); - write_header_seg(&live.p, &doc, &live.st, h.get("engine").and_then(Json::as_str).unwrap_or(""), Some(sg), HEADER_CHUNK, &mut |c: &[u8]| b.extend_from_slice(c)); - String::from_utf8(b).unwrap_or_default() } }; - if header != head { return Err((1, "the header is not as written".into())); } + pub fn open(head: &str) -> Result { Replay::open_boxed(head).map(|r| *r) } + /// `open` on the heap (docs/embedding.md "Stack budget"): the header's checks of `open`, in its order with its messages, run as + /// the chain check runs them (the persona built on the heap, the state read into a box, the header written again piece by + /// piece against the line, never whole), and the individual is built in its box: no frame holds a `Live`, a `Persona` or the + /// header text. `open` is this call with the replay moved out of its box (the replay itself is small: the individual stays + /// boxed in it). + #[inline(never)] + pub fn open_boxed(head: &str) -> Result, (usize, String)> { + let (p, h, st0, prev) = open_head(head)?; + let doc = h.get("document").cloned().unwrap_or(Json::Null); + let dev_full = engine_version(h.get("engine").and_then(Json::as_str).unwrap_or("")).is_some_and(|v| v < DEV_R6_SINCE); + let expect = p.expect.as_ref().map(|_| new_mem(dev_full)); + let live = Live::boxed(p, st0, Clock::Fixed, prev, 0, expect, dev_full); + Ok(Box::new(Replay::at_header(live, h, doc))) + } + /// The replay of a life just started from its header + #[inline(never)] + fn at_header(live: Box, header: Json, doc: Json) -> Replay { let norms = Normals::start(&live.p, &live.st); let at_cap = vec![0.0; persona::deficits(&live.p, &live.st).len()]; let seen = (0.0, vec![0.0; persona::gate_gains(&live.p, &live.st).len()]); let viab = viab_start(&live); - Ok(Replay { live, header: h, doc, line: 1, from: None, last: None, checkpoint_ready: false, norms, at_cap, seen, viab, rest: None }) + Replay { live, header, doc, line: 1, from: None, last: None, checkpoint_ready: false, norms, at_cap, seen, viab, rest: None } } /// The replay from a checkpoint line (`cp`, without its line ending, at 1-based line `at`; `before` = the event line before it) /// instead of the header: the header is read as by `open`; the checkpoint must follow `before` (prev), at its event count, with @@ -639,9 +643,9 @@ pub fn engine() -> String { format!("probbit {}", env!("CARGO_PKG_VERSION")) } pub fn check_chain(text: &str) -> Result { check_chain_head(text).map(|(s, _, _)| s) } /// The header checks of `Replay::open` without a replay (the same checks, in its order, with its messages): the header read, its /// persona built on the heap, its digest and initial state checked, and the header written again piece by piece against the line -/// (no `Live`, no whole header) -> the persona and the header +/// (no `Live`, no whole header) -> the persona, the header, its initial state and the header's sha256 #[inline(never)] -fn open_head(head: &str) -> Result<(Box, Json), (usize, String)> { +fn open_head(head: &str) -> Result<(Box, Json, Box, String), (usize, String)> { let h = json::parse(head).map_err(|e| (1, format!("the header is not JSON: {}", e.msg)))?; if h.get("probbit_strand").and_then(Json::as_f64) != Some(FORMAT) { return Err((1, "not a probbit strand (format 1)".into())); } if let Some((k, _)) = h.as_obj().and_then(|kv| kv.iter().find(|(k, _)| !HEADER.contains(&k.as_str()) && k != "segment")) { return Err((1, unsupported(k))); } @@ -651,9 +655,9 @@ fn open_head(head: &str) -> Result<(Box, Json), (usize, String)> { if h.get("persona").and_then(|x| x.get("digest")).and_then(Json::as_str) != Some(p.digest.as_str()) { return Err((1, "the persona digest does not match the document".into())); } let st0 = read_boxed(&p, h.get("state").unwrap_or(&Json::Null)).map_err(|e| (1, read_err(e, "the initial state: ")))?; let (hb, mut at, mut same) = (head.as_bytes(), 0usize, true); - write_header_seg(&p, doc, &st0, h.get("engine").and_then(Json::as_str).unwrap_or(""), h.get("segment"), HEADER_CHUNK, &mut |c: &[u8]| { same = same && hb.get(at..at + c.len()) == Some(c); at += c.len(); }); + let digest = write_header_seg(&p, doc, &st0, h.get("engine").and_then(Json::as_str).unwrap_or(""), h.get("segment"), HEADER_CHUNK, &mut |c: &[u8]| { same = same && hb.get(at..at + c.len()) == Some(c); at += c.len(); }); if !same || at != hb.len() { return Err((1, "the header is not as written".into())); } - Ok((p, h)) + Ok((p, h, st0, digest)) } /// A checkpoint line's own checks (`cp`, without its line ending, at 1-based line `at`; `before` = the line before it) for persona /// `p`, with `open_at`'s messages: canonical JSON, its four fields, right after an event line (prev), at that event's count, with @@ -695,7 +699,7 @@ fn read_boxed(p: &Persona, j: &Json) -> Result, InErr> { State::read( fn check_chain_head(text: &str) -> Result<(Status, Box, Json), InErr> { let ls = lines(text); let err = |line: usize, why: String| perr("strand", format!("line {line}: {why}")); - let (hp, h) = open_head(ls.first().copied().unwrap_or("")).map_err(|(i, why)| err(i, why))?; + let (hp, h, _, _) = open_head(ls.first().copied().unwrap_or("")).map_err(|(i, why)| err(i, why))?; let canon = !engine_version(h.get("engine").and_then(Json::as_str).unwrap_or("")).is_some_and(|v| v < DEV_R6_SINCE); let mut status = Status::Active; let mut n = 0u64; From a73946d8903d5296cf671286b410720a27202f19 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 08:03:33 -0700 Subject: [PATCH 20/61] =?UTF-8?q?probbit-persona:=20resume=20from=20a=20re?= =?UTF-8?q?cord=20behind=20the=20strand:=20strand::resume=5Ffrom=5Frecord(?= =?UTF-8?q?text,=20persona,=20state,=20clock,=20engine)=20chain-checks=20t?= =?UTF-8?q?he=20strand=20as=20resume=20does,=20finds=20the=20line=20that?= =?UTF-8?q?=20logs=20the=20record's=20state,=20starts=20at=20the=20last=20?= =?UTF-8?q?checkpoint=20at=20or=20before=20it=20(the=20header=20without=20?= =?UTF-8?q?one)=20and=20applies=20every=20event=20line=20after=20it:=20the?= =?UTF-8?q?=20turn=20run=20on=20the=20logged=20inputs=20(no=20expectation?= =?UTF-8?q?=20member=20solves:=20the=20logged=20deviation=20is=20folded=20?= =?UTF-8?q?as=20resume=20folds=20it),=20the=20stance=20and=20state=20diges?= =?UTF-8?q?ts=20and=20the=20line's=20bytes=20checked=20(its=20deviation=20?= =?UTF-8?q?and=20glyph=20taken=20as=20written),=20rest=20and=20control=20l?= =?UTF-8?q?ines=20applied=20as=20the=20live=20writer=20applies=20them;=20r?= =?UTF-8?q?efused=20with=20the=20line=20for=20a=20record=20the=20strand=20?= =?UTF-8?q?does=20not=20log,=20a=20line=20that=20differs=20from=20its=20ap?= =?UTF-8?q?plication,=20another=20persona;=20turn=5Ftrace:=20trace::last?= =?UTF-8?q?=5Fresume()=20(lines=20walked,=20lines=20applied,=20their=20?= =?UTF-8?q?=C2=B5s);=20test:=20a=20499/500-event=20life=20of=20the=20expec?= =?UTF-8?q?tation=20fixture=20persona=20(engines=200.13.0=20and=200.14.0,?= =?UTF-8?q?=20a=20checkpoint=20every=2050)=20resumed=20by=20the=20boxed=20?= =?UTF-8?q?replay=20and=20from=20records=200,=201,=205=20and=2049=20behind?= =?UTF-8?q?=20reaches=20the=20unbroken=20state,=20head=20and=20memory,=20a?= =?UTF-8?q?nd=20its=20next=2010=20events=20write=20the=20unbroken=20lines;?= =?UTF-8?q?=20a=20forged=20line=20re-chained=20is=20refused=20at=20that=20?= =?UTF-8?q?line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- probbit-cli/src/live.rs | 57 ++++++++++++++ probbit-persona/src/strand.rs | 138 +++++++++++++++++++++++++++++++++- probbit-persona/src/trace.rs | 24 +++++- 3 files changed, 214 insertions(+), 5 deletions(-) diff --git a/probbit-cli/src/live.rs b/probbit-cli/src/live.rs index ad017fe..aa47bed 100644 --- a/probbit-cli/src/live.rs +++ b/probbit-cli/src/live.rs @@ -496,6 +496,63 @@ mod tests { assert_eq!(verify(&on.0, &run).unwrap().get("events"), Some(&Json::Num(700.0))); } } } + /// S2 (docs/persona.md §5.7, "Resume from a record"): a life recorded unbroken, and the same life resumed by each path: the + /// boxed replay (every stance document and the individual it reaches), a record 1, 5 and 49 lines behind the strand + /// (`resume_from_record`: the trailing event lines applied, not solved), and the embedded resume from the header, each + /// checkpoint line and the lines after it (`resume_boxed_from`; the record last, and 5 behind). Each reaches the unbroken + /// state, chain head and expectation memory, and its next 10 events write the unbroken lines. Refused: a record the strand + /// does not log (or one before the checkpoint), a forged line re-chained, another persona. Expectation fixture persona, + /// engines 0.13.0 and 0.14.0, a checkpoint every 50; the glyph persona (no expectation block) with k = 1 and 3. + #[test] + fn every_resume_path_writes_the_unbroken_life() { + let y = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/monitor/tutor-expect.yaml")).unwrap(); + let d = probbit_persona::yaml::load(&y).unwrap_or_else(|e| panic!("{}", e.msg)); let p = persona::build(&d).unwrap(); + let mut d2 = d.clone(); put(&mut d2, &["identity", "version"], Json::Str("9".into())); let p2 = persona::build(&d2).unwrap(); assert_ne!(p2.digest, p.digest); + let mem = |l: &Live| l.expect.as_ref().map(|m| persona::canon(&m.to_json())); + let next10 = |l: &mut Live, from: usize| (from..from + 10).map(|t| l.event(&s1_event(t), &run).unwrap().1).collect::>(); + for engine in ["probbit 0.13.0", "probbit 0.14.0"] { for n_end in [499usize, 500] { + // the unbroken life: the text, the documents, the state after every event, then 10 more events + let (mut lv, head) = Live::start(p.clone(), &d, persona::init(&p, Some(2), true, &run), Clock::Fixed, engine); + let (mut text, mut docs, mut sts) = (format!("{head}\n"), vec![], vec![lv.st.clone()]); + for t in 0..n_end { let (s, l) = lv.event(&s1_event(t), &run).unwrap(); text += &l; text.push('\n'); docs.push(persona::canon(&s)); sts.push(lv.st.clone()); + if lv.n % 50 == 0 { text += &lv.checkpoint(&s); text.push('\n'); } } + let (want_st, want_head, want_mem) = (lv.st.digest.clone(), lv.head().to_string(), mem(&lv)); let want_next = next10(&mut lv, n_end); + let ctx = format!("{engine}, {n_end} events"); + let same = |r: &mut Live, how: &str| { assert_eq!((r.n, r.st.digest.as_str(), r.head()), (n_end as u64, want_st.as_str(), want_head.as_str()), "{how} ({ctx})"); + assert_eq!(mem(&*r), want_mem, "{how}: the expectation's memory ({ctx})"); assert_eq!(next10(r, n_end), want_next, "{how}: the next 10 lines ({ctx})"); }; + // A: the boxed replay, every stance document + let ls = lines(&text); let mut r = Replay::open_boxed(ls[0]).unwrap(); let mut k = 0; + for l in &ls[1..] { if let Some(s) = r.step(l, &run).unwrap() { assert_eq!(persona::canon(&s), docs[k], "boxed replay, event {} ({ctx})", k + 1); k += 1; } } + assert_eq!(k, n_end); same(&mut *r.live, "the boxed replay"); + // B: a record k lines behind (k = 0: the last; it equals resume's) + for kb in [0usize, 1, 5, 49] { + let mut b = resume_from_record(&text, &p, Box::new(sts[n_end - kb].clone()), Clock::Fixed, &run).unwrap_or_else(|e| panic!("k {kb}: {}: {} ({ctx})", e.path, e.msg)); + same(&mut *b, &format!("a record {kb} behind")); } + let mut b = resume(&text, &p, sts[n_end].clone(), Clock::Fixed).unwrap(); same(&mut b, "resume"); + // refusals: a record from another life; a record before the checkpoint (C); a forged line, re-chained; another persona + let other = persona::init(&p, Some(9), true, &run); + assert_eq!(resume_from_record(&text, &p, Box::new(other.clone()), Clock::Fixed, &run).err().unwrap().path, "state"); + let mut f: Vec = ls.iter().map(|l| l.to_string()).collect(); let at = f.len() - 3; + let mut j = json::parse(&f[at]).unwrap(); if let Json::Obj(v) = &mut j { if let Some((_, Json::Obj(ins))) = v.iter_mut().find(|(k, _)| k == "inputs") { + if let Some((_, x)) = ins.iter_mut().find(|(k, _)| k == "praise") { *x = Json::Bool(!matches!(x, Json::Bool(true))); } } } + f[at] = persona::canon(&j); + for i in at + 1..f.len() { let mut j = json::parse(&f[i]).unwrap(); let pd = persona::digest_of(&f[i - 1]); + if let Json::Obj(v) = &mut j { if let Some((_, x)) = v.iter_mut().find(|(k, _)| k == "prev") { *x = Json::Str(pd); } } f[i] = persona::canon(&j); } + let forged = f.join("\n") + "\n"; + let e = resume_from_record(&forged, &p, Box::new(sts[n_end - 5].clone()), Clock::Fixed, &run).err().unwrap(); + assert!(e.msg.contains(&format!("line {}: the ", at + 1)) && e.msg.contains("differs"), "forged: {} ({ctx})", e.msg); + assert!(resume_from_record(&text, &p2, Box::new(sts[n_end - 5].clone()), Clock::Fixed, &run).err().unwrap().msg.contains("another persona")); + } } + // no expectation block, with glyphs: the logged glyph is taken as written and the line checked around it + let on = with_display(&json::parse(GDOC).unwrap(), &[("display", GLYPHS)]); let pn = persona::build(&on).unwrap(); + let (mut lv, head) = Live::start(pn.clone(), &on, persona::init(&pn, Some(1), true, &run), Clock::Fixed, "probbit test"); + let (mut text, mut sts) = (format!("{head}\n"), vec![lv.st.clone()]); + for t in 0..40 { let (_, l) = lv.event(&gev(t), &run).unwrap(); text += &l; text.push('\n'); sts.push(lv.st.clone()); } + let want: Vec = (40..50).map(|t| lv.event(&gev(t), &run).unwrap().1).collect(); + for kb in [1usize, 3] { let mut b = resume_from_record(&text, &pn, Box::new(sts[40 - kb].clone()), Clock::Fixed, &run).unwrap(); + assert_eq!((40..50).map(|t| b.event(&gev(t), &run).unwrap().1).collect::>(), want, "glyph persona, k {kb}"); } + } + /// S1 measurements (ignored: run with --ignored --nocapture): the expectation fixture persona, 500 events, a checkpoint every /// 50: checkpoint line bytes (max_combos 16 and 64), and resume's time at 166 and 500 events, 0.13.0 (fold from the header) /// against 0.14.0 (fold from the last checkpoint), with the full replay for scale diff --git a/probbit-persona/src/strand.rs b/probbit-persona/src/strand.rs index 5b7372c..a076baf 100644 --- a/probbit-persona/src/strand.rs +++ b/probbit-persona/src/strand.rs @@ -792,14 +792,146 @@ pub fn resume_boxed(text: &str, p: &Persona, st: Box, clock: Clock) -> Re Ok(lv) } +/// Continue a strand from a state record that may be behind it (docs/persona.md §5.7, "Resume from a record"): `st` is the state +/// some line logs (an event line's `state`, a checkpoint's, or the header's), not necessarily the last; a reboot between a line +/// and its record leaves the record k lines behind. The whole strand is chain-checked as by `resume`; then the walk starts at the +/// last checkpoint at or before the record's line (the header without one), and every event line after the record's line is +/// **applied** to the record: its turn run on the logged inputs (the main solve; the expectation's member solves are not run: the +/// line's logged deviation is folded as `resume` folds it), the stance and state digests and the line's bytes checked (its logged +/// `deviation` and `glyph` taken as written: `verify` recomputes them), rest and control lines applied as the live writer applies +/// them. -> the individual after the strand's last line, as a header replay reaches it, byte for byte. Refused, with the line, +/// when the record is no state the strand logs, a line differs from its application, or the persona is another one. +#[inline(never)] +pub fn resume_from_record(text: &str, p: &Persona, st: Box, clock: Clock, eng: Engine) -> Result, InErr> { + let bad = |m: String| perr("strand", m); + if !text.ends_with('\n') { return Err(bad("its last line is incomplete (no newline at the end)".into())); } + let (_, q, h) = check_chain_head(text)?; + let ls = lines(text); + // the record's line: the last whose logged state is the record (each logs its own: the state carries the turn) + let want = format!("\"{}\"", st.digest); + let a = (1..ls.len()).rev().find(|&i| ls[i].contains(&want)).unwrap_or(0); + let dev_full = engine_version(h.get("engine").and_then(Json::as_str).unwrap_or("")).is_some_and(|v| v < DEV_R6_SINCE); + // the walk starts at the last checkpoint at or before it (a segment's sealed line at the latest), whose memory it reads; the + // header otherwise (and for an expectation block before 0.14.0, whose checkpoints do not carry the memory) + let c = last_checkpoint(&ls[..=a.max(1).min(ls.len() - 1)], 0).filter(|_| !(dev_full && q.expect.is_some()) || h.get("segment").is_some()); + let (cp, from) = match c { Some(c) => (Some((ls[c], c + 1)), c + 1), None => (None, 1) }; + resume_walk(q, &h, ls[0], cp, ls[from..].iter().copied(), p, st, clock, eng) +} +/// The walk of a resume: from the header (`cp` None) or a checkpoint line (and its line number), over the lines after it. +/// Every line is chain-checked; the record `st` anchors at the line that logs its state; the event lines after that line are +/// applied (`apply_line`); the expectation's memory is the start's, folded over every event line. +#[inline(never)] +#[allow(clippy::too_many_arguments)] +fn resume_walk>(q: Box, h: &Json, head: &str, cp: Option<(&str, usize)>, trailing: impl IntoIterator, p: &Persona, st: Box, clock: Clock, eng: Engine) -> Result, InErr> { + let sw = crate::trace::Stopwatch::start(); + let mut tr = crate::trace::ResumeTrace::default(); + let err = |line: usize, why: String| perr("strand", format!("line {line}: {why}")); + if q.digest != p.digest { return Err(perr("strand", format!("it is another persona's ({} != {})", &q.digest[..19], &p.digest[..19]))); } + let dev_full = engine_version(h.get("engine").and_then(Json::as_str).unwrap_or("")).is_some_and(|v| v < DEV_R6_SINCE); + let canon = !dev_full; + let (mut n, mut prev_line, logged, mut mem, mut at) = match cp { + None => (0, head.to_string(), h.get("state").and_then(|s| s.get("digest")).and_then(Json::as_str).unwrap_or("").to_string(), q.expect.as_ref().map(|_| new_mem(dev_full)), 1), + Some((c, at)) => { let (cn, _, cst, cmem) = check_checkpoint(&q, canon, None, c, at).map_err(|(i, why)| err(i, why))?; + let mem = if q.expect.is_some() { cmem } else { None }; + (cn, c.to_string(), cst.digest.clone(), mem, at) } }; + let mut prev = persona::digest_of(&prev_line); + // the record: anchored once the walk reaches the line that logs its state; from there on it is the individual's state + let mut rec = Some(st); + let mut cur: Option> = if rec.as_ref().is_some_and(|s| s.digest == logged) { rec.take() } else { None }; + let (mut status, mut tail, mut rest) = (Status::Active, 0u64, None::); + for l in trailing { + let l = l.as_ref(); at += 1; tr.lines += 1; + let j = json::parse(l).map_err(|e| err(at, format!("not JSON: {}", e.msg)))?; + if persona::canon(&j) != l { return Err(err(at, "not canonical JSON".into())); } + if j.get("prev").and_then(Json::as_str) != Some(prev.as_str()) { return Err(err(at, "prev is not the sha256 of the line before".into())); } + if let Some(k) = unknown_key(&j) { return Err(err(at, unsupported(k))); } + if rest.is_some() && !matches!(kind(&j), Kind::Event) { return Err(err(at - 1, "a rest line must be followed by its event line".into())); } + match kind(&j) { + Kind::Rest => { if status != Status::Active { return Err(err(at, format!("rest line while {}", status.name()))); } rest = Some(l.to_string()); } + Kind::Control => { let f = |k: &str| j.get(k).and_then(Json::as_str).unwrap_or(""); + status = status.after(f("control")).map_err(|e| err(at, e.msg))?; + let line = control_line(f("prev"), f("control"), f("by"), f("reason"), f("at")).map_err(|e| err(at, e.msg))?; + if line != l { return Err(err(at, "the control line differs".into())); } + // as the live writer: no credit crosses a control line; `erase` returns the learned weights to zero + if let Some(s) = cur.as_mut() { let mut z = Box::new(s.zero_credit(&q)); if f("control") == "erase" { z = Box::new(z.erased(&q)); } *s = z; } + tail += 1; } + Kind::Checkpoint => { + if status != Status::Active { return Err(err(at, format!("checkpoint while {}", status.name()))); } + let (cn, _, cst, cmem) = check_checkpoint(&q, canon, Some(&prev_line), l, at).map_err(|(i, why)| err(i, why))?; + if cn != n { return Err(err(at, "checkpoint event count differs".into())); } + if q.expect.is_some() && cmem.is_some() { mem = cmem; } + if cur.is_none() && rec.as_ref().is_some_and(|s| s.digest == cst.digest) { cur = rec.take(); } + tail = 0; } + Kind::Event => { + if status != Status::Active { return Err(err(at, format!("event while {}", status.name()))); } + n += 1; + if j.get("n").and_then(Json::as_f64) != Some(n as f64) { return Err(err(at, format!("n is not {n}"))); } + let Some(Json::Obj(_)) = j.get("inputs") else { return Err(err(at, "no input object".into())) }; + for k in ["state", "stance"] { + if !j.get(k).and_then(Json::as_str).is_some_and(|s| s.len() == 71 && s.starts_with("sha256:") && s[7..].bytes().all(|c| c.is_ascii_hexdigit())) { + return Err(err(at, format!("invalid {k} digest"))); + } + } + let logged = j.get("state").and_then(Json::as_str).unwrap_or(""); + match cur.as_mut() { + Some(s) => { let t0 = crate::trace::Stopwatch::start(); + *s = apply_line(&q, s, &j, l, rest.as_deref(), eng).map_err(|why| err(at, why))?; + tr.applied += 1; tr.applied_us += t0.us(); } + None => if rec.as_ref().is_some_and(|s| s.digest == logged) { cur = rec.take(); } } + rest = None; + if let Some(m) = mem.as_mut() { persona::expect_step(&q, m, j.get("inputs").unwrap_or(&Json::Null), logged_pred(&j)); } + tail = 0; } } + prev = persona::digest_of(l); prev_line.clear(); prev_line.push_str(l); + } + if rest.is_some() { return Err(err(at, "a rest line must be followed by its event line".into())); } + let Some(st) = cur else { + let d = rec.as_ref().map_or("", |s| s.digest.as_str()); + return Err(perr("state", format!("not a state this strand logs from where the resume starts ({}): neither the start's nor any line's after it", &d[..d.len().min(19)]))); + }; + let mut lv = Live::boxed(q, st, clock, prev, n, mem, dev_full); + lv.status = status; lv.controls = tail; + tr.total_us = sw.us(); crate::trace::resumed(tr); + Ok(lv) +} +/// One recorded event line applied to the state before it (`resume_from_record`): the line's inputs stamped as the live writer +/// stamps them on the fixed clock (its logged `elapsed_hours`), a rest line due before it recomputed against the logged one, the +/// turn run, and the line written again from what the turn gives (n, inputs, stance digest, state digest, prev) with the logged +/// `deviation` and `glyph` -> the state after it, or what differs. Without the expectation's member solves: the deviation and +/// the glyph that reads it are the line's. +#[inline(never)] +fn apply_line(p: &Persona, st: &State, j: &Json, l: &str, rest: Option<&str>, eng: Engine) -> Result, String> { + let ev = j.get("inputs").unwrap_or(&Json::Null); + let Json::Obj(kv) = ev else { return Err("no input object".into()) }; + let mut kv: Vec<(String, Json)> = kv.iter().filter(|(k, _)| k != "elapsed_hours").cloned().collect(); + let e = match ev.get("elapsed_hours") { None => 0.0, Some(Json::Num(x)) if x.is_finite() && *x >= 0.0 => *x, _ => return Err("event.elapsed_hours: a number >= 0".into()) }; + kv.push(("elapsed_hours".into(), Json::Num(q6(e)))); + let inputs = Json::Obj(kv); + match (persona::rest_line_digest(p, st, &inputs), rest) { + (Some((r, rd)), Some(lr)) => { let rp = json::parse(lr).ok().and_then(|x| x.get("prev").and_then(Json::as_str).map(str::to_string)).unwrap_or_default(); + if rest_line(&rp, r, &rd) != lr { return Err("the rest line differs".into()); } } + (Some(_), None) => return Err("a rest line is due before this event and missing".into()), + (None, Some(_)) => return Err("a rest line where none is due".into()), + (None, None) => {} } + let (stance, ns) = persona::turn_boxed(p, st, &inputs, false, eng, false).map_err(|e| read_err(e, ""))?; + let sd = persona::stance_sha(&stance); + if j.get("stance").and_then(Json::as_str) != Some(sd.as_str()) { return Err("the stance differs".into()); } + if j.get("state").and_then(Json::as_str) != Some(ns.digest.as_str()) { return Err("the state differs".into()); } + let mut lj = vec![("n".into(), j.get("n").cloned().unwrap_or(Json::Null)), ("inputs".into(), inputs), ("stance".into(), Json::Str(sd)), + ("state".into(), Json::Str(ns.digest.clone())), ("prev".into(), j.get("prev").cloned().unwrap_or(Json::Null))]; + for k in ["deviation", "glyph"] { if let Some(x) = j.get(k) { lj.push((k.into(), x.clone())); } } + if persona::canon(&Json::Obj(lj)) != l { return Err("the line differs".into()); } + Ok(ns) +} +/// RFC 0009: an event line's logged prediction (none on a cold start), the value the expectation's memory folds +fn logged_pred(j: &Json) -> Option { j.get("deviation").filter(|d| d.get("cold").is_none()).and_then(|d| d.get("pred")).and_then(Json::as_f64) } + /// The expectation's empty memory: the canonical fold from 0.14.0 (`dev_full` false), the fold before it otherwise fn new_mem(dev_full: bool) -> persona::ExpectMem { if dev_full { persona::ExpectMem::new() } else { persona::ExpectMem::canonical() } } /// RFC 0009: the expectation's memory `m` with the event lines of `lines` folded in, one line read at a time #[inline(never)] fn expect_fold(q: &Persona, mut m: persona::ExpectMem, lines: &[&str]) -> persona::ExpectMem { - for l in lines.iter() { if let Ok(j) = json::parse(l) { if matches!(kind(&j), Kind::Event) { let d = j.get("deviation"); - let lp = d.filter(|d| d.get("cold").is_none()).and_then(|d| d.get("pred")).and_then(Json::as_f64); - persona::expect_step(q, &mut m, j.get("inputs").unwrap_or(&Json::Null), lp); } } } + for l in lines.iter() { if let Ok(j) = json::parse(l) { if matches!(kind(&j), Kind::Event) { + persona::expect_step(q, &mut m, j.get("inputs").unwrap_or(&Json::Null), logged_pred(&j)); } } } m } /// The audit of a recorded life (`probbit persona bound PERSONA --audit STRAND`): the strand replayed line by line; after every diff --git a/probbit-persona/src/trace.rs b/probbit-persona/src/trace.rs index 62e5137..66d87fd 100644 --- a/probbit-persona/src/trace.rs +++ b/probbit-persona/src/trace.rs @@ -42,6 +42,12 @@ pub const NAMES: [&str; PHASES] = ["input", "expect", "prep", "compile", "solve" #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct TurnTrace { pub us: [u64; PHASES], pub bytes: [u64; PHASES], pub total_us: u64, pub solves: u32, pub expect_cached: u32, pub expect_solved: u32 } +/// A resume's walk (`strand::resume_from_record`, `strand::resume_boxed_from`): the lines read after its start, the event lines +/// applied to a record that was behind (each one turn without the expectation's member solves) and their microseconds, and the +/// whole call's microseconds (with the feature off: the counts, zero microseconds) +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ResumeTrace { pub lines: u64, pub applied: u64, pub applied_us: u64, pub total_us: u64 } + #[cfg(feature = "turn_trace")] mod on { use super::*; @@ -66,6 +72,13 @@ mod on { let mut g = t.borrow_mut(); if let Some(b) = b { let x = &mut g.acc.bytes[prev as usize]; *x = (*x).max(b); } g.open = Some((t0, us(t0))); g.cur = ph; prev }) } + thread_local! { static RT: RefCell = RefCell::new(ResumeTrace::default()); } + /// The last resume's walk on this thread (zeros before the first) + pub fn last_resume() -> ResumeTrace { RT.with(|r| *r.borrow()) } + pub(crate) fn resumed(t: ResumeTrace) { RT.with(|r| *r.borrow_mut() = t) } + /// A clock read for the resume's counts + pub(crate) struct Stopwatch(Instant); + impl Stopwatch { pub(crate) fn start() -> Stopwatch { Stopwatch(Instant::now()) } pub(crate) fn us(&self) -> u64 { us(self.0) } } pub(crate) fn expect_cache(hits: u32, solves: u32) { T.with(|t| { let mut g = t.borrow_mut(); if g.open.is_some() { g.acc.expect_cached += hits; g.acc.expect_solved += solves; } }) } pub(crate) fn end() { let cur = T.with(|t| t.borrow().cur); enter(cur); @@ -73,9 +86,9 @@ mod on { } } #[cfg(feature = "turn_trace")] -pub use on::{last, set_probe}; +pub use on::{last, last_resume, set_probe}; #[cfg(feature = "turn_trace")] -pub(crate) use on::{begin, end, enter, expect_cache}; +pub(crate) use on::{begin, end, enter, expect_cache, resumed, Stopwatch}; #[cfg(not(feature = "turn_trace"))] #[inline(always)] pub(crate) fn begin() {} @@ -88,6 +101,13 @@ pub(crate) fn end() {} #[cfg(not(feature = "turn_trace"))] #[inline(always)] pub(crate) fn expect_cache(_hits: u32, _solves: u32) {} +#[cfg(not(feature = "turn_trace"))] +#[inline(always)] +pub(crate) fn resumed(_t: ResumeTrace) {} +#[cfg(not(feature = "turn_trace"))] +pub(crate) struct Stopwatch; +#[cfg(not(feature = "turn_trace"))] +impl Stopwatch { #[inline(always)] pub(crate) fn start() -> Stopwatch { Stopwatch } #[inline(always)] pub(crate) fn us(&self) -> u64 { 0 } } impl TurnTrace { /// As JSON: `{"us": {phase: µs}, "bytes": {phase: value}, "total_us": n, "solves": n, "expect_cached": n, "expect_solved": n}` From 326f8b162aa71ce7cdc691437f5d3963f22843e1 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 08:03:34 -0700 Subject: [PATCH 21/61] probbit-persona: the embedded resume: strand::resume_boxed_from(header, last checkpoint line, its line number, the lines after it as an iterator, persona, record, clock, engine): the header checked as verify checks it (one build, written again in pieces), the checkpoint by its own fields (its link is the host's stored offset, as a segment's sealed line is taken on its link), the memory read from it, the trailing lines chain-checked one at a time and the record anchored at the line that logs it (applied after it as by resume_from_record): O(trailing lines), the whole strand never loaded; refused before 0.14.0 for an expectation persona (its checkpoints carry no memory), for a segment without its sealed line, for a record older than the checkpoint; test: from the header and from every checkpoint, record last and 5 behind, equal to the unbroken life --- probbit-cli/src/live.rs | 13 +++++++++++++ probbit-persona/src/strand.rs | 22 +++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/probbit-cli/src/live.rs b/probbit-cli/src/live.rs index aa47bed..6df9abf 100644 --- a/probbit-cli/src/live.rs +++ b/probbit-cli/src/live.rs @@ -529,9 +529,22 @@ mod tests { let mut b = resume_from_record(&text, &p, Box::new(sts[n_end - kb].clone()), Clock::Fixed, &run).unwrap_or_else(|e| panic!("k {kb}: {}: {} ({ctx})", e.path, e.msg)); same(&mut *b, &format!("a record {kb} behind")); } let mut b = resume(&text, &p, sts[n_end].clone(), Clock::Fixed).unwrap(); same(&mut b, "resume"); + // C: from the header (no checkpoint) and from every checkpoint line, the trailing lines one at a time + let cps: Vec = (1..ls.len()).filter(|&i| ls[i].starts_with(r#"{"checkpoint":"#)).collect(); + if engine == "probbit 0.13.0" { assert!(resume_boxed_from(ls[0], Some(ls[cps[0]]), cps[0] + 1, ls[cps[0] + 1..].iter(), &p, Box::new(sts[n_end].clone()), Clock::Fixed, &run).is_err()); } + let starts: Vec> = if engine == "probbit 0.13.0" { vec![None] } else { std::iter::once(None).chain(cps.iter().copied().map(Some)).collect() }; + for c in starts { for kb in [0usize, 5] { let (cp, from) = c.map_or((None, 1), |c| (Some(ls[c]), c + 1)); + // a record older than the checkpoint is refused (below); the walk starts at the checkpoint + if cp.and_then(|l| json::parse(l).ok()).and_then(|j| j.get("checkpoint").and_then(Json::as_f64)).is_some_and(|cn| cn as usize > n_end - kb) { continue; } + let tr: Vec = ls[from..].iter().map(|l| l.to_string()).collect(); + let mut x = resume_boxed_from(ls[0], cp, from, tr, &p, Box::new(sts[n_end - kb].clone()), Clock::Fixed, &run) + .unwrap_or_else(|e| panic!("from line {from}, k {kb}: {}: {} ({ctx})", e.path, e.msg)); + same(&mut *x, &format!("the embedded resume from line {from}, k {kb}")); } } // refusals: a record from another life; a record before the checkpoint (C); a forged line, re-chained; another persona let other = persona::init(&p, Some(9), true, &run); assert_eq!(resume_from_record(&text, &p, Box::new(other.clone()), Clock::Fixed, &run).err().unwrap().path, "state"); + if let Some(&c) = cps.last() { if engine == "probbit 0.14.0" { + assert_eq!(resume_boxed_from(ls[0], Some(ls[c]), c + 1, ls[c + 1..].iter(), &p, Box::new(sts[1].clone()), Clock::Fixed, &run).err().unwrap().path, "state"); } } let mut f: Vec = ls.iter().map(|l| l.to_string()).collect(); let at = f.len() - 3; let mut j = json::parse(&f[at]).unwrap(); if let Json::Obj(v) = &mut j { if let Some((_, Json::Obj(ins))) = v.iter_mut().find(|(k, _)| k == "inputs") { if let Some((_, x)) = ins.iter_mut().find(|(k, _)| k == "praise") { *x = Json::Bool(!matches!(x, Json::Bool(true))); } } } diff --git a/probbit-persona/src/strand.rs b/probbit-persona/src/strand.rs index a076baf..7633e3e 100644 --- a/probbit-persona/src/strand.rs +++ b/probbit-persona/src/strand.rs @@ -817,7 +817,27 @@ pub fn resume_from_record(text: &str, p: &Persona, st: Box, clock: Clock, let (cp, from) = match c { Some(c) => (Some((ls[c], c + 1)), c + 1), None => (None, 1) }; resume_walk(q, &h, ls[0], cp, ls[from..].iter().copied(), p, st, clock, eng) } -/// The walk of a resume: from the header (`cp` None) or a checkpoint line (and its line number), over the lines after it. +/// The embedded resume (docs/embedding.md "Resume on a body"): continue a strand from its header line `head` (the persona +/// document, the engine and the initial state it carries), its last checkpoint line `cp` (None before the first: the walk starts +/// at the header) and the lines after that checkpoint, read one at a time from `trailing` (a host reads them from its store at the +/// checkpoint's offset), never the whole strand. `line` is the checkpoint's 1-based line number in the strand, for messages (0: +/// lines are counted from the checkpoint). The header is checked as `verify` checks it (built once, written again in pieces); the +/// checkpoint line by its own fields (canonical, its state and memory read, at an event count): its link to the line before it is +/// the host's (the offset it stored), as a segment's sealed line is taken on its link. Each trailing line is chain-checked as +/// `resume` checks it, and the record `st` may be any state logged from the checkpoint on (the event lines after it are applied, as +/// by `resume_from_record`). Cost: O(trailing lines), plus the header's check and the persona's build. +#[inline(never)] +#[allow(clippy::too_many_arguments)] +pub fn resume_boxed_from>(head: &str, cp: Option<&str>, line: usize, trailing: impl IntoIterator, p: &Persona, st: Box, clock: Clock, eng: Engine) -> Result, InErr> { + let (q, h, _, _) = open_head(head).map_err(|(i, why)| perr("strand", format!("line {i}: {why}")))?; + let dev_full = engine_version(h.get("engine").and_then(Json::as_str).unwrap_or("")).is_some_and(|v| v < DEV_R6_SINCE); + if cp.is_some() && dev_full && q.expect.is_some() { + return Err(perr("strand", "before engine 0.14.0 a checkpoint does not carry the expectation's memory: resume from the whole strand")); + } + if cp.is_none() && h.get("segment").is_some() { return Err(perr("strand", "a segment resumes from its sealed line (line 2), not from its header")); } + resume_walk(q, &h, head, cp.map(|c| (c, line.max(1))), trailing, p, st, clock, eng) +} +/// The walk both resumes share: from the header (`cp` None) or a checkpoint line (and its line number), over the lines after it. /// Every line is chain-checked; the record `st` anchors at the line that logs its state; the event lines after that line are /// applied (`apply_line`); the expectation's memory is the start's, folded over every event line. #[inline(never)] From f132b789aee04fe5bc0310cfdbbc3c0c9bf2e8a0 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 08:09:46 -0700 Subject: [PATCH 22/61] probbit-persona: resume_from_record applies a line from small frames: the logged inputs stamped, the rest line checked and the line checked around the turn each in its own frame, so the turn runs under the walk with nothing else held (proxy: 16,400 -> 16,080 B for a record 1, 5 or 49 lines behind) --- probbit-persona/src/strand.rs | 41 +++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/probbit-persona/src/strand.rs b/probbit-persona/src/strand.rs index 7633e3e..efd484b 100644 --- a/probbit-persona/src/strand.rs +++ b/probbit-persona/src/strand.rs @@ -920,27 +920,44 @@ fn resume_walk>(q: Box, h: &Json, head: &str, cp: Option< /// the glyph that reads it are the line's. #[inline(never)] fn apply_line(p: &Persona, st: &State, j: &Json, l: &str, rest: Option<&str>, eng: Engine) -> Result, String> { + let inputs = stamp_logged(j)?; + rest_check(p, st, &inputs, rest)?; + // the turn from the smallest frame: its document and state only (the checks are their own frames, before and after) + let (stance, ns) = persona::turn_boxed(p, st, &inputs, false, eng, false).map_err(|e| read_err(e, ""))?; + line_check(j, l, inputs, &stance, &ns.digest)?; + Ok(ns) +} +/// A logged event's inputs stamped as the live writer stamps them on the fixed clock (its `elapsed_hours`, quantised, last) +#[inline(never)] +fn stamp_logged(j: &Json) -> Result { let ev = j.get("inputs").unwrap_or(&Json::Null); let Json::Obj(kv) = ev else { return Err("no input object".into()) }; let mut kv: Vec<(String, Json)> = kv.iter().filter(|(k, _)| k != "elapsed_hours").cloned().collect(); let e = match ev.get("elapsed_hours") { None => 0.0, Some(Json::Num(x)) if x.is_finite() && *x >= 0.0 => *x, _ => return Err("event.elapsed_hours: a number >= 0".into()) }; - kv.push(("elapsed_hours".into(), Json::Num(q6(e)))); - let inputs = Json::Obj(kv); - match (persona::rest_line_digest(p, st, &inputs), rest) { + kv.push(("elapsed_hours".into(), Json::Num(q6(e)))); Ok(Json::Obj(kv)) +} +/// A rest line due before the event (RFC 0002 part 2) recomputed against the logged one +#[inline(never)] +fn rest_check(p: &Persona, st: &State, inputs: &Json, rest: Option<&str>) -> Result<(), String> { + match (persona::rest_line_digest(p, st, inputs), rest) { (Some((r, rd)), Some(lr)) => { let rp = json::parse(lr).ok().and_then(|x| x.get("prev").and_then(Json::as_str).map(str::to_string)).unwrap_or_default(); - if rest_line(&rp, r, &rd) != lr { return Err("the rest line differs".into()); } } - (Some(_), None) => return Err("a rest line is due before this event and missing".into()), - (None, Some(_)) => return Err("a rest line where none is due".into()), - (None, None) => {} } - let (stance, ns) = persona::turn_boxed(p, st, &inputs, false, eng, false).map_err(|e| read_err(e, ""))?; - let sd = persona::stance_sha(&stance); + if rest_line(&rp, r, &rd) != lr { return Err("the rest line differs".into()); } Ok(()) } + (Some(_), None) => Err("a rest line is due before this event and missing".into()), + (None, Some(_)) => Err("a rest line where none is due".into()), + (None, None) => Ok(()) } +} +/// The applied event's line against the logged one: its stance and state digests, then its bytes (n, inputs, stance, state, +/// prev as the turn gives them; the logged `deviation` and `glyph` as written) +#[inline(never)] +fn line_check(j: &Json, l: &str, inputs: Json, stance: &Json, state: &str) -> Result<(), String> { + let sd = persona::stance_sha(stance); if j.get("stance").and_then(Json::as_str) != Some(sd.as_str()) { return Err("the stance differs".into()); } - if j.get("state").and_then(Json::as_str) != Some(ns.digest.as_str()) { return Err("the state differs".into()); } + if j.get("state").and_then(Json::as_str) != Some(state) { return Err("the state differs".into()); } let mut lj = vec![("n".into(), j.get("n").cloned().unwrap_or(Json::Null)), ("inputs".into(), inputs), ("stance".into(), Json::Str(sd)), - ("state".into(), Json::Str(ns.digest.clone())), ("prev".into(), j.get("prev").cloned().unwrap_or(Json::Null))]; + ("state".into(), Json::Str(state.to_string())), ("prev".into(), j.get("prev").cloned().unwrap_or(Json::Null))]; for k in ["deviation", "glyph"] { if let Some(x) = j.get(k) { lj.push((k.into(), x.clone())); } } if persona::canon(&Json::Obj(lj)) != l { return Err("the line differs".into()); } - Ok(ns) + Ok(()) } /// RFC 0009: an event line's logged prediction (none on a cold start), the value the expectation's memory folds fn logged_pred(j: &Json) -> Option { j.get("deviation").filter(|d| d.get("cold").is_none()).and_then(|d| d.get("pred")).and_then(Json::as_f64) } From bfa028ff13775bb5f575286a70063c39366eb719 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 08:19:25 -0700 Subject: [PATCH 23/61] docs: resume on a body (docs/embedding.md: what a host keeps, the embedded call, the three recoveries: record last, record k lines behind, no record; the cost model and the proxy's stack rows) and resume from a record (docs/persona.md 5.7: what it promises, how a line is applied, what it refuses and does not check); CHANGELOG --- CHANGELOG.md | 10 ++++++++++ docs/embedding.md | 48 +++++++++++++++++++++++++++++++++++++++++++++++ docs/persona.md | 25 ++++++++++++++++++++++++ 3 files changed, 83 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 580c757..eda9fd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project are documented here. The format follows ## Unreleased +- **Resume robustness** (docs/embedding.md "Resume on a body", docs/persona.md section 5.7 "Resume from a record"): + `strand::Replay::open_boxed` checks the header as the chain check does (the persona built on the heap, the header written + again in pieces) and keeps its `Live` boxed, so a replay from the header fits a small stack (proxy: 24,752 → 16,192 B); + `Replay::open` is the same call. `strand::resume_from_record(text, &p, state, clock, &engine)` resumes from a state record + that is behind the strand (a reboot between a line and its record): the event lines after the record's line are applied + (their turn on the logged inputs, digests and bytes checked; not the expectation's member solves) and the individual equals + the unbroken one, byte for byte; refused with the line for a record the strand does not log, a line that differs, another + persona. `strand::resume_boxed_from(header, checkpoint, line, trailing, &p, state, clock, &engine)` resumes from the header, + the last checkpoint line and the lines after it read one at a time, never the whole strand: cost O(lines since the + checkpoint). `turn_trace`: `trace::last_resume()` (lines walked, lines applied, their µs). No byte of any strand changes. - **The expectation's cache** (docs/embedding.md "The expectation's cache"): `Live` keeps the exact marginals of the programs the deviation solved, keyed by the program without its comment, so an event whose members' programs are the previous event's solves none of them again (a life of one repeated event: `expect` 52% → 37% of the turn on the proxy, 95% diff --git a/docs/embedding.md b/docs/embedding.md index 3e1f047..8d799a6 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -68,6 +68,8 @@ let st = persona::init_boxed(&p, None, true, &eng); // Box< let live = Live::start_to(p, &doc, st, Clock::Real(probbit_core::rt::Instant::now()), &strand::engine(), 1024, &mut out); // or: let (live, header) = Live::start_boxed(p, &doc, st, clock, &engine); // Box and the header whole // a stored strand: let live = strand::resume_boxed(&text, &p, st, clock)?; // Box +// or from the last checkpoint without the whole text: strand::resume_boxed_from(&header, Some(&cp), line, trailing, &p, st, clock, &eng)? +// a replay from the header: let r = strand::Replay::open_boxed(&header)?; // Box, its Live boxed in it // each event: live.event(&ev, &eng)? -> (stance, line); the turn inside passes its state boxed (persona::turn_boxed) ``` @@ -240,6 +242,52 @@ deletes the older ones holds a life of any length in about twice that; `verify - it: a boot parses and hashes the lines of that one segment, folds the expectation's memory over the lines after its last checkpoint only, and never solves a stored event again. +### Resume on a body: the call and the three recoveries + +A body keeps three things between boots: the persona document (it rides in the strand's header line, which also names the +engine and the initial state), **the byte offset of the last checkpoint line** it wrote, and **its last state record** (the +state after the event it last answered, in its own small log). On boot it reads the header line, the checkpoint line at the +stored offset and then the lines after it one at a time, and calls + +```rust +// trailing: an iterator over the lines after the checkpoint (each without its line ending), read from flash as it goes +let live = strand::resume_boxed_from(&header, Some(&checkpoint), checkpoint_line_number, trailing, &p, record, clock, &eng)?; +// before the first checkpoint: resume_boxed_from(&header, None, 1, lines_after_the_header, ...) +``` + +The header is checked as `verify` checks it (the persona built once on the heap, the header written again in pieces against +the stored line); the checkpoint line by its own fields (canonical JSON, its state and the expectation's memory read, an event +count), its link to the line before taken on the host's offset, as a segment's sealed line is taken on its link; every line +after it is chain-checked as `resume` checks it. The whole strand is never loaded, and nothing before the checkpoint is read +again. The three recoveries: + +1. **The record is the strand's last state** (the normal boot): the walk reads the trailing lines, finds the record at the last + event line and returns the individual; no turn runs. +2. **The record is k lines behind** (a reboot between a line and its record): the record is found at the line that logs it and + the k event lines after it are applied (each its turn's main solve, not the expectation's member solves; docs/persona.md + §5.7 "Resume from a record"). The result is the individual the unbroken life has, byte for byte; nothing needs the host to + resync. A record older than the checkpoint is refused (`state`): resume from the whole strand with + `strand::resume_from_record(text, ...)`, which starts at the last checkpoint before the record. +3. **No record** (a first boot on a copied strand, a lost state log): replay from the header with `strand::Replay::open_boxed` + (every event solved again, the cost of `verify`), or start at the checkpoint's state: a checkpoint line carries the whole + state, so a record taken from it (`State::read` of its `state`) resumes at once and applies the lines after it. + +The cost on the host proxy (probbit-persona `nostd` on x86_64, the 12 KB body persona; the strand: 50 events, one checkpoint, +then T event lines; record last; median of 7): `resume_boxed_from` = **1.46 ms fixed + 13.4 µs per trailing line** (T = 0, 58, +200, 449: 1,459, 2,328, 4,284, 7,456 µs), against the whole-text `resume_boxed` at 2.3 ms + about 15 µs for every line of the +strand. The fixed part is the header's check and the persona's build plus the checkpoint's state and memory read. An applied +line (recovery 2) costs about 612 µs where the turn costs 2,580 µs on that life (0.24 of a turn: the expectation's member +solves, most of this persona's turn, are not run). A body measured the whole-text resume at about 488 ms fixed + 8 ms per +line, so its boot cost now follows the lines since its last checkpoint, not the length of its life (silicon numbers for the +new call are the body's to measure, with `turn_trace`: `trace::last_resume()` gives the lines walked, the lines applied and +their µs). + +Stack on the proxy (the same harness as the table above, 20 KB thread run too): `Replay::open_boxed` and its steps over a +500-event strand **16,192 B** (the by-value `Replay::open` was 24,752 B: it built the persona and the `Live` by value and held +the `Live` in the replay; `open` is now `open_boxed` moved out of its box, 16,736 B with the replay in the caller's frame); +`resume_from_record` with a record 1, 5 or 49 lines behind **16,080 B** (13,648 with the record last); `resume_boxed_from` a +checkpoint + 58 lines **12,656 B** (the whole-text `resume_boxed` of the same strand 15,072 B). All three ran on a 20 KB thread. + ### Measured on one board (ESP32-S3) First numbers from one ESP32-S3 at 240 MHz, the engine on one core shared with a 16 fps renderer, with the boxed setup path diff --git a/docs/persona.md b/docs/persona.md index 392f5f8..195de27 100644 --- a/docs/persona.md +++ b/docs/persona.md @@ -1563,6 +1563,31 @@ most about 64 + (max_combos - 1) x (I + 24) bytes for events whose inputs are at engine before 0.14.0 keeps the four-key checkpoint and the fold before it, byte for byte; such a strand with the block still replays from its header. An engine before 0.14.0 reads `expect` as `unsupported by this engine: expect` (exit 5). +**Resume from a record.** A host that keeps the individual's state in its own record (a state log beside the strand) can +reboot between a strand line and its record, so the record is one or more lines behind the strand. `strand::resume` refuses a +state that is not the strand's last; `strand::resume_from_record(text, &p, state, clock, &engine)` takes any state the strand +logs (an event line's `state`, a checkpoint's, the header's) and brings it to the strand's last line. What it promises: the +individual it returns is the one a header replay reaches at the last line (state, chain head, the expectation's memory, the +status after control lines), byte for byte and digest for digest, and the next event writes the line the unbroken life writes. +How: the whole strand is chain-checked as by `resume`; the walk starts at the last checkpoint at or before the record's line +(the header without one, and for a persona with an expectation block before 0.14.0, whose checkpoints do not carry its +memory); every event line after the record's line is applied: its turn run on the logged inputs (stamped as the live writer +stamped them), its stance and state digests compared, the line written again from the turn's values and compared byte for +byte. An event line logs its stance's digest, not the stance, and the next state is decoded from the solve (the levels and +odds the moods' inertia and the credit read), so an applied line costs its turn's main solve; it does not run the +expectation's member solves: the line's logged `deviation` (and the `glyph` that reads it) is taken as written and folded into +the memory as `resume` folds it. Rest lines are recomputed against the logged ones, control lines applied as the live writer +applies them (the credit zeroed, `erase` returning the learned weights to zero). Refused, with the line: a record that is no +state the strand logs (`state`), a line whose application differs (the stance, the state, the rest line or the line's bytes: +a forged line, re-chained or not), a chain that does not hold, another persona. What it does not check: the logged deviation +and glyph of the applied lines (only `verify` recomputes the expectation); the lines before the walk's start beyond the chain +check `resume` makes. + +`strand::resume_boxed_from(header, Some(checkpoint), line, trailing, &p, state, clock, &engine)` is the same walk without the +whole text (docs/embedding.md "Resume on a body"): the header line, the last checkpoint line (taken on the link the host kept, +like a segment's sealed line) and the lines after it one at a time. The record may be any state logged from that checkpoint on; +an older one is refused (`state`: resume from the whole strand). + **Segments.** `probbit live ... --strand NAME.strand --segment-every N` seals the strand after every N-th event and goes on in a new file: the sealing line is a checkpoint line (the last line of the segment); the next file, `NAME.0001.strand`, then `NAME.0002.strand` and so on (four digits at least), starts with a header that has one more key at its end, From a2b850eb0e779cbe106028a2dcbaa9b934612bc7 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 09:15:37 -0700 Subject: [PATCH 24/61] probbit-persona: the expectation's members as models: the event's own program is read once (run::from_json_parts), every other member is compiled without its text (Opts::meta_only) and, with A_t's rules in force, solved as A_t's model with its own h (Parts::model_with: the compile pass and Model::new as from_json runs them) by the exact tier run would answer with (run::enumerated: components over the limit, enumeration within it; another tier: the text, from the member's meta by emit, run by the engine); the cache keys these programs by A_t's skeleton and their field; cache off (persona::deviation) is the reference text path; turn_trace: expect_build_us / expect_solve_us; test: the members as models move no byte (a sensed persona with needs, drives and a habit, engines 0.13.0 / 0.14.0, max_combos 6 / 16; the expectation fixture persona) --- probbit-cli/src/live.rs | 30 ++++++ probbit-persona/src/persona.rs | 175 ++++++++++++++++++++++++++++----- probbit-persona/src/run.rs | 59 ++++++++++- probbit-persona/src/trace.rs | 17 +++- 4 files changed, 248 insertions(+), 33 deletions(-) diff --git a/probbit-cli/src/live.rs b/probbit-cli/src/live.rs index 6df9abf..c0108dd 100644 --- a/probbit-cli/src/live.rs +++ b/probbit-cli/src/live.rs @@ -496,6 +496,36 @@ mod tests { assert_eq!(verify(&on.0, &run).unwrap().get("events"), Some(&Json::Num(700.0))); } } } + /// T3: the expectation's members as models move no byte. The same lives run with the cache off (every member's program + /// built as text and run by the engine: the reference) and on (each member compiled without its text and solved as the + /// event's own model with its own `h`; another tier or other rules in force: built and run): every stance document, every + /// strand line (a checkpoint every 10) and the final state are the same. A sensed persona whose readings are floats that move + /// every event (a number sense, two needs, drives, a habit on a flag: rules that differ between members), engines 0.13.0 + /// and 0.14.0, max_combos 6 and 16; and the expectation fixture persona (500 varied events, max_combos 16). Both paths run. + #[test] + fn the_members_as_models_move_no_byte() { + let load = |rel: &str| probbit_persona::yaml::load(&std::fs::read_to_string(format!("{}/../{rel}", env!("CARGO_MANIFEST_DIR"))).unwrap()).unwrap_or_else(|e| panic!("{}", e.msg)); + let sensed = |t: usize| { let h = (t as u64).wrapping_mul(2654435761) >> 7; let b = |k: u64| (h >> k) & 1 == 1; + let needs = if b(9) && b(10) { r#","needs":{"context":0.2}"# } else { "" }; + json::parse(&format!(r#"{{"quiet":{},"senses":{{"tokens":{}}},"src":"env:host","elapsed_hours":{}{needs}}}"#, b(2) && b(5), ((h >> 11) % 997) as f64 / 1000.0, [0.1, 0.5, 1.0][((h >> 15) % 3) as usize])).unwrap() }; + let mut cases: Vec<(Json, usize, &str, usize, bool)> = vec![]; + for mc in [6usize, 16] { for engine in ["probbit 0.13.0", "probbit 0.14.0"] { let mut d = load("bench/body/fixtures/needs2.yaml"); + put(&mut d, &["expectation"], json::parse(&format!(r#"{{"max_combos":{mc}}}"#)).unwrap()); cases.push((d, 120, engine, 0, true)); } } + let mut d = load("probbit-cli/tests/fixtures/monitor/tutor-expect.yaml"); put(&mut d, &["expectation"], json::parse(r#"{"max_combos":16}"#).unwrap()); cases.push((d, 500, "probbit 0.14.0", 1, false)); + for (d, n, engine, kind, fallback) in &cases { let p = persona::build(d).unwrap_or_else(|e| panic!("{}: {}", e.path, e.msg)); + let ev = |t: usize| if *kind == 0 { sensed(t) } else { s1_event(t) }; + let life = |on: bool| { let (mut lv, head) = Live::start(p.clone(), d, persona::init(&p, Some(2), true, &run), Clock::Fixed, engine); + lv.expect_cache = persona::ExpectCache::new(on); let (mut text, mut docs, mut modeled, mut texted) = (format!("{head}\n"), vec![], 0u32, 0u32); + for t in 0..*n { let (s, l) = lv.event(&ev(t), &run).unwrap_or_else(|e| panic!("event {t}: {}: {}", e.path, e.msg)); text += &l; text.push('\n'); docs.push(persona::canon(&s)); + let (m, (_, sv)) = (lv.expect_cache.modeled(), lv.expect_cache.last()); modeled += m; texted += sv - m; if lv.n % 10 == 0 { text += &lv.checkpoint(&s); text.push('\n'); } } + (text, docs, lv.st.digest.clone(), modeled, texted) }; + let (on, off) = (life(true), life(false)); let ctx = format!("{} events, {engine}, {}", n, persona::canon(d.get("expectation").unwrap())); + assert_eq!(on.1, off.1, "every stance document ({ctx})"); assert_eq!(on.0, off.0, "every strand byte ({ctx})"); assert_eq!(on.2, off.2, "the final state ({ctx})"); + assert!(on.3 > 0 && off.3 == 0, "programs solved as models: on {} off {} ({ctx})", on.3, off.3); + if *fallback { assert!(on.4 > 0, "programs built and run with the cache on: {} ({ctx})", on.4); } + assert_eq!(verify(&on.0, &run).unwrap().get("events"), Some(&Json::Num(*n as f64)), "{ctx}"); } + } + /// S2 (docs/persona.md §5.7, "Resume from a record"): a life recorded unbroken, and the same life resumed by each path: the /// boxed replay (every stance document and the individual it reaches), a record 1, 5 and 49 lines behind the strand /// (`resume_from_record`: the trailing event lines applied, not solved), and the embedded resume from the header, each diff --git a/probbit-persona/src/persona.rs b/probbit-persona/src/persona.rs index 27e13c1..a0e4407 100644 --- a/probbit-persona/src/persona.rs +++ b/probbit-persona/src/persona.rs @@ -2487,9 +2487,11 @@ fn flags(p: &Persona, st: &State) -> Flags { #[derive(Clone)] 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)>, new_learned: Table, contrib: Vec)>>, field: Vec>, values: Vec, twin: bool, - lift: Vec<(String, f64)>, learn_sign: f64 } + lift: Vec<(String, f64)>, learn_sign: f64, seed: u64, turn: u64 } #[derive(Default, Clone)] -struct Opts { no_inertia: bool, clamps: Vec<(String, String)>, twin: Option, exclude: Vec, hist: Option> } +struct Opts { no_inertia: bool, clamps: Vec<(String, String)>, twin: Option, exclude: Vec, hist: Option>, + /// RFC 0009 (the expectation's members): the compiler's arithmetic and the rules in force, no program text (Json::Null) + meta_only: bool } /// Inputs -> (values per declared input, history features, elapsed hours, ignored ids); a bad value is a persona error #[allow(clippy::type_complexity)] @@ -2568,12 +2570,7 @@ fn compile(p: &Persona, st: &State, raw: &[(String, Json)], o: &Opts) -> R<(Json 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 sem = values_of(p); // `pursue`'s drive terms after its prior, learned, input and history contributions: genes, wanting, afterglow, deadline (§3 step 2b) let pix = dctx.as_ref().and_then(|_| p.vars.iter().position(|v| v.id == "pursue")); if let (Some(c), Some(i)) = (&dctx, pix) { for (src, vec) in &c.terms { if vec.iter().any(|x| *x != 0.0) { contrib[i].push((src.to_string(), vec.clone())); } } } @@ -2586,11 +2583,28 @@ fn compile(p: &Persona, st: &State, raw: &[(String, Json)], o: &Opts) -> R<(Json let l = floor_lifts(p, d, &field[i], &pursue_allowed(&p.vars[i].levels, &rules[ti])); if l.iter().any(|x| *x != 0.0) { contrib[i].push(("floor".into(), l.clone())); field[i] = total(&contrib[i], l.len()); } lift = d.goals.iter().zip(&l).filter(|(_, x)| **x != 0.0).map(|(g, x)| (g.id.clone(), *x)).collect(); } } + let use_twin = o.twin.unwrap_or(p.eng.twin); + 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, new_learned, contrib, field, values: sem, twin: use_twin, lift, learn_sign, + seed: st.seed, turn: st.turn }; + if o.meta_only { return Ok((Json::Null, meta)); } + Ok((emit(p, &meta, &o.clamps), meta)) +} +/// The program's text from the compiler's meta: values, vars (traits and moods, then the agenda steps) with their `h` from the +/// field, pairs, the rules in force (renamed), the comment (persona, seed, turn, active habits); positional = level i -> l +#[inline(never)] +fn emit(p: &Persona, meta: &Meta, clamps: &[(String, String)]) -> Json { + let (nv, ns) = (p.vars.len(), p.steps.len()); let (field, rules, use_twin) = (&meta.field, &meta.rules, meta.twin); + let pos_vals = p.eng.positional; + 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 { meta.values.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 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)))); } + if let Some((_, c)) = 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)), @@ -2603,9 +2617,8 @@ fn compile(p: &Persona, st: &State, raw: &[(String, Json)], o: &Opts) -> R<(Json 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 comment = format!("probbit persona {} {} seed {} turn {}; active habits: {}", p.name, p.version, meta.seed, meta.turn, + if meta.active.is_empty() { "none".to_string() } else { meta.active.join(", ") }); 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())); } } @@ -2620,9 +2633,7 @@ fn compile(p: &Persona, st: &State, raw: &[(String, Json)], o: &Opts) -> R<(Json 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, new_learned, contrib, field, values: sem, twin: use_twin, lift, learn_sign }; - Ok((Json::Obj(prog), meta)) + Json::Obj(prog) } /// 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 { @@ -3104,14 +3115,17 @@ pub fn program(p: &Persona, st: &State, raw: &Json) -> R { Ok(text(&prog /// `program` as a document (RFC 0009: the expectation's members and the trajectory compile through it); the steps before the /// compiler pass the state boxed, as in the turn #[inline(never)] -pub fn program_json(p: &Persona, st: &State, raw: &Json) -> R { +pub fn program_json(p: &Persona, st: &State, raw: &Json) -> R { Ok(program_meta(p, st, raw, false)?.0) } +/// `program_json` with the compiler's meta; `meta_only`: no program text (Json::Null), the field and the rules in force only +#[inline(never)] +fn program_meta(p: &Persona, st: &State, raw: &Json, meta_only: bool) -> R<(Json, Box)> { let raw: Vec<(String, Json)> = match raw { Json::Null => vec![], Json::Obj(v) => v.clone(), _ => return Err(perr("inputs", "must be a JSON object")) }; let (src, raw) = if p.senses.is_some() || p.credit().is_some() || p.has_needs() { take_src(p, raw, true)? } else { (None, raw) }; let (pay, raw) = take_pay(p, raw)?; let (st, raw) = prep_boxed(p, st, &raw, src.as_ref(), true)?; let st = match &pay { Some(k) => credit_boxed(p, &st, &raw, k, src.as_ref(), true)?.0, None => st }; let st = gate_boxed(p, st, &raw)?; - Ok(compile(p, &st, &raw, &Opts::default())?.0) + let (j, m) = compile(p, &st, &raw, &Opts { meta_only, ..Default::default() })?; Ok((j, Box::new(m))) } /// Static check: for every conditional habit and every pair of them (with every unconditional one), and every previous level of @@ -4018,15 +4032,17 @@ fn exact_marg(p: &Persona, st: &State, prog: &Json, eng: Engine) -> Option /// one the cache is not consulted for the next 1, 2, 4, ... 32 events (a hit resets it; the rows are dropped meanwhile and the event /// after the pause fills them for the one after it to compare), so such a life keys two events in 34. #[derive(Clone, Debug)] -pub struct ExpectCache { on: bool, rows: Vec<([u8; 32], Marg)>, used: Vec, hits: u32, solves: u32, skip: u32, back: u32, live: bool, had: bool } +pub struct ExpectCache { on: bool, rows: Vec<([u8; 32], Marg)>, used: Vec, hits: u32, solves: u32, skip: u32, back: u32, live: bool, had: bool, modeled: u32 } impl Default for ExpectCache { fn default() -> ExpectCache { ExpectCache::new(true) } } impl ExpectCache { /// An empty cache; `on: false` solves every program (the receipt is the same) - pub fn new(on: bool) -> ExpectCache { ExpectCache { on, rows: vec![], used: vec![], hits: 0, solves: 0, skip: 0, back: 0, live: false, had: false } } + pub fn new(on: bool) -> ExpectCache { ExpectCache { on, rows: vec![], used: vec![], hits: 0, solves: 0, skip: 0, back: 0, live: false, had: false, modeled: 0 } } /// Whether the cache is used pub fn is_on(&self) -> bool { self.on } /// The last event's programs answered from the cache and solved pub fn last(&self) -> (u32, u32) { (self.hits, self.solves) } + /// The last event's programs solved as the event's own model with their own `h` (the rest of the solves: built and run) + pub fn modeled(&self) -> u32 { self.modeled } /// The rows held (at most the persona's `max_combos + 1`) pub fn len(&self) -> usize { self.rows.len() } /// Whether no row is held @@ -4037,12 +4053,22 @@ impl ExpectCache { self.rows.capacity() * size_of::<([u8; 32], Marg)>() + self.used.capacity() + self.rows.iter().map(|(_, m)| m.capacity() * size_of::<(String, Vec<(String, f64)>)>() + m.iter().map(|(id, ls)| id.capacity() + ls.capacity() * size_of::<(String, f64)>() + ls.iter().map(|(l, _)| l.capacity()).sum::()).sum::()).sum::() } - fn begin(&mut self) { self.hits = 0; self.solves = 0; self.used.clear(); self.used.resize(self.rows.len(), false); + fn begin(&mut self) { self.hits = 0; self.solves = 0; self.modeled = 0; self.used.clear(); self.used.resize(self.rows.len(), false); self.live = self.on && self.skip == 0; self.had = !self.rows.is_empty(); if self.skip > 0 { self.skip -= 1; } } fn end(&mut self) { // an event that had rows to compare and found none backs off; one that started empty only fills (the next one compares) if self.live { if self.hits > 0 { self.back = 0; } else if self.had { self.back = (self.back * 2).clamp(1, 32); self.skip = self.back; } } let mut i = 0; self.rows.retain(|_| { i += 1; self.used[i - 1] }); self.used.clear(); } + /// A row by key (None when the cache is paused or has none) + fn find(&mut self, key: &[u8; 32]) -> Option { + if !self.live { return None; } + let i = self.rows.iter().position(|r| r.0 == *key)?; self.used[i] = true; self.hits += 1; Some(self.rows[i].1.clone()) + } + /// A program solved here, not by `marg`: counted, and kept under `key` while the cache is live + fn keep(&mut self, key: Option<[u8; 32]>, m: &Option) { + self.solves += 1; + if let (true, Some(k), Some(m)) = (self.live, key, m) { self.rows.push((k, m.clone())); self.used.push(true); } + } /// The program's exact marginals: from the cache when the program (without its comment) was solved before, else solved (the /// key's hasher lives in its own frame, not in the one that calls the engine) #[inline(never)] @@ -4097,17 +4123,20 @@ pub fn deviation_cached(p: &Persona, st: &State, raw: &Json, mem: &ExpectMem, en fn deviation_in(p: &Persona, st: &State, raw: &Json, mem: &ExpectMem, eng: Engine, cache: &mut ExpectCache) -> Option { let ex = p.expect.as_ref()?; let kv = raw.as_obj()?; let e = get(kv, "elapsed_hours").cloned().unwrap_or(Json::Num(0.0)); let key = combo_key(p, kv); - let mut solve = |ins: &Json| program_json(p, st, ins).ok().and_then(|prog| cache.marg(p, st, &prog, eng)); - let a = solve(raw)?; let ed = solve(&with_elapsed(&Json::Obj(vec![]), &e))?; let ws = expect_weights(ex, mem); let member = ws.iter().any(|(k, _)| *k == key); + // the programs: the event's own (A_t), the one with the hours only (E_t), then each other member's (its combo's latest inputs + // with this event's hours), in the mixture's order + let mut ins = vec![with_elapsed(&Json::Obj(vec![]), &e)]; + for (k, _) in &ws { if *k == key || k == "{}" { continue; } ins.push(with_elapsed(&mem.combos.iter().find(|x| x.0 == *k).map(|x| x.2.clone()).unwrap_or(Json::Obj(vec![])), &e)); } + let mut ms = members_marg(p, st, raw, &ins, eng, cache)?.into_iter(); + let a = ms.next()??; let ed = ms.next()??; let cold = !mem.warm && key == "{}"; // the members' marginals, weighted; a member that does not compile or solve exactly folds into the empty member let mut parts: Vec<(f64, Marg)> = vec![]; let mut empty_w = 0.0; for (k, w) in &ws { if *k == key { parts.push((*w, a.clone())); continue; } if k == "{}" { empty_w += w; continue; } - let rep = mem.combos.iter().find(|x| x.0 == *k).map(|x| x.2.clone()).unwrap_or(Json::Obj(vec![])); - match solve(&with_elapsed(&rep, &e)) { Some(m) => parts.push((*w, m)), None => empty_w += w } } + match ms.next().flatten() { Some(m) => parts.push((*w, m)), None => empty_w += w } } // the empty member: E_t, or A_t itself when the event is time-only (ws[0], pushed first) if key != "{}" { parts.push((empty_w, ed.clone())); } else { parts[0].0 += empty_w; } let pi: Marg = a.iter().map(|(id, la)| (id.clone(), la.iter().map(|(l, _)| (l.clone(), parts.iter().fold(0.0, |acc, (w, m)| @@ -4117,6 +4146,104 @@ fn deviation_in(p: &Persona, st: &State, raw: &Json, mem: &ExpectMem, eng: Engin let z = (!cold).then(|| ((1.0 + sig5(pred)).ln() - mem.mean) / (mem.var + DEV_VAR_FLOOR).sqrt()); Some(Deviation { s_self, pred, input: -w_cur.ln(), cold, member, z, members: ws.len(), terms: a.iter().zip(tp.iter().zip(&ts)).map(|((id, _), (x, y))| (id.clone(), *x, *y)).collect() }) } +/// A program's exact marginals the reference way: its text built (`program_json`) and run by the engine (`ExpectCache::marg`); +/// turn_trace: the build and the solve timed apart (`expect_build_us`, `expect_solve_us`) +#[inline(never)] +fn text_marg(p: &Persona, st: &State, ins: &Json, eng: Engine, cache: &mut ExpectCache) -> Option { + let t = crate::trace::Stopwatch::start(); let prog = program_json(p, st, ins).ok(); let b = t.us(); + let m = prog.and_then(|prog| cache.marg(p, st, &prog, eng)); crate::trace::expect_split(b, t.us() - b); m +} +/// The event's own program's model parts, read once from its text (`run::from_json_parts`), with per variable of the persona +/// (traits and moods, then the agenda steps) the value index of each of its levels in the program +struct Skel { parts: crate::run::Parts, idx: Vec> } +/// The expectation's programs -> per program its exact marginals (None: it does not compile or is not solved exactly); None when +/// A_t or E_t has none (no deviation). With the cache off (`persona::deviation`, the reference) every program's text is built +/// and run by the engine. With it on, A_t's text is built and read once; every other program is compiled without its text +/// (`Opts::meta_only`) and, when its rules in force are A_t's, solved as A_t's model with its own `h` by the enumeration +/// `run_program` would answer with (`run::enumerated`): the same model, the same marginals, bit for bit. Any other program +/// (other rules, another tier) is built and run as the reference does. The cache's key for these programs: A_t's text without +/// `comment` and the vars' `h` (its skeleton), then the program's field: equal keys, equal texts. +#[inline(never)] +fn members_marg(p: &Persona, st: &State, raw: &Json, ins: &[Json], eng: Engine, cache: &mut ExpectCache) -> Option>> { + let mut out = Vec::with_capacity(ins.len() + 1); + if !cache.on { out.push(text_marg(p, st, raw, eng, cache)); if out[0].is_none() { return None; } + for (i, x) in ins.iter().enumerate() { let m = text_marg(p, st, x, eng, cache); if i == 0 && m.is_none() { return None; } out.push(m); } + return Some(out); } + let t = crate::trace::Stopwatch::start(); + let Ok((prog, meta)) = program_meta(p, st, raw, false) else { crate::trace::expect_split(t.us(), 0); return None }; + let (b, t) = (t.us(), crate::trace::Stopwatch::start()); + let skel = cache.live.then(|| skel_key(&prog)); + let mut sk: Option>> = None; // read on the first program the cache does not answer; Some(None): not usable + let a = model_marg(p, st, &prog, &meta, skel.as_ref(), &mut sk, true, eng, cache).flatten(); crate::trace::expect_split(b, t.us()); + a.as_ref()?; out.push(a); + for (i, x) in ins.iter().enumerate() { + let t = crate::trace::Stopwatch::start(); + let m = match program_meta(p, st, x, true) { + Err(_) => { crate::trace::expect_split(t.us(), 0); None } + Ok((_, mm)) if mm.rules == meta.rules => { let (b, t) = (t.us(), crate::trace::Stopwatch::start()); + let r = model_marg(p, st, &prog, &mm, skel.as_ref(), &mut sk, false, eng, cache); + // another tier (or no usable skeleton): its text from its meta, run by the engine + let r = match r { Some(r) => r, None => { let prog = emit(p, &mm, &[]); cache.marg(p, st, &prog, eng) } }; crate::trace::expect_split(b, t.us()); r } + // other rules in force: its text from its meta (no second build), run by the engine + Ok((_, mm)) => { let prog = emit(p, &mm, &[]); let (b, t) = (t.us(), crate::trace::Stopwatch::start()); let m = cache.marg(p, st, &prog, eng); crate::trace::expect_split(b, t.us()); m } }; + if i == 0 && m.is_none() { return None; } + out.push(m); } + Some(out) +} +/// One program of the event as A_t's model with its own `h` (from its compile meta `mm`; `own`: A_t itself, its parsed `h`): +/// Some(marginals or None) as the engine would answer; None when the enumeration would not answer it (the caller runs it) +#[inline(never)] +#[allow(clippy::too_many_arguments)] +fn model_marg(p: &Persona, st: &State, prog: &Json, mm: &Meta, skel: Option<&[u8; 32]>, sk: &mut Option>>, own: bool, eng: Engine, cache: &mut ExpectCache) -> Option> { + let key = skel.map(|s| field_key(s, &mm.field)); + if let Some(k) = &key { if let Some(m) = cache.find(k) { return Some(Some(m)); } } + if sk.is_none() { *sk = Some(skel_parts(p, prog, mm)); } + let Some(Some(s)) = sk.as_ref() else { return if own { Some(cache.marg(p, st, prog, eng)) } else { None } }; + let h = if own { s.parts.h().to_vec() } else { field_h(s, &mm.field) }; + let Ok(mut m) = s.parts.model_with(h) else { cache.keep(key, &None); return Some(None) }; + let Some(r) = crate::run::enumerated(&mut m, p.eng.exact_limit, p.eng.sweeps) else { return if own { Some(cache.marg(p, st, prog, eng)) } else { None } }; + let r = r.map(|mg| { let k = m.k; s.parts.vars.iter().enumerate().filter(|(_, id)| !id.starts_with("free.")).map(|(i, id)| (id.clone(), { + let mut l: Vec<(String, f64)> = (0..k).filter(|&q| m.allowed[i * k + q]).map(|q| (s.parts.values[q].clone(), mg[i * k + q])).collect(); l.sort_by(|a, b| a.0.cmp(&b.0)); l })).collect() }); + cache.modeled += 1; cache.keep(key, &r); Some(r) +} +/// A_t's text read into its parts, and the value index of every level -> None when the parts do not have the shape the compiler +/// writes (then every program is run the reference way); checked: A_t's own field mapped to `h` is the `h` its text carries +#[inline(never)] +fn skel_parts(p: &Persona, prog: &Json, mm: &Meta) -> Option> { + let parts = crate::run::from_json_parts(prog).ok()?; + let (nv, ns) = (p.vars.len(), p.steps.len()); + if parts.vars.len() != (nv + ns) * if mm.twin { 2 } else { 1 } { return None; } + let q = |name: &str| parts.values.iter().position(|x| x == name); + let mut idx = vec![]; + for v in &p.vars { idx.push(v.levels.iter().enumerate().map(|(j, l)| q(&if p.eng.positional { format!("l{j}") } else { l.clone() })).collect::>>()?); } + for _ in &p.steps { idx.push(p.slots.iter().map(|l| q(l)).collect::>>()?); } + let s = Box::new(Skel { parts, idx }); + (field_h(&s, &mm.field).iter().zip(s.parts.h()).all(|(a, b)| a.to_bits() == b.to_bits())).then_some(s) +} +/// A field (per persona variable, per level) as the program's `h` (n x k): the value index each level gets, 0 where the text +/// omits a weight, the twins' rows copied +fn field_h(s: &Skel, field: &[Vec]) -> Vec { + let (n, k, m) = (s.parts.vars.len(), s.parts.values.len(), s.idx.len()); let mut h = vec![0.0; n * k]; + for r in 0..n { let i = r % m; for (&q, &w) in s.idx[i].iter().zip(&field[i]) { if w != 0.0 { h[r * k + q] = w; } } } + h +} +/// A_t's text without `comment` and without the vars' `h`, hashed as `prog_key` hashes a program +#[inline(never)] +fn skel_key(prog: &Json) -> [u8; 32] { + let mut h = Sha256::new(); h.update(b"skeleton"); + if let Json::Obj(v) = prog { for (k, x) in v.iter().filter(|(k, _)| k != "comment") { hash_str(&mut h, k); + match (k.as_str(), x) { ("vars", Json::Arr(vs)) => { h.update(b"["); h.update(&(vs.len() as u64).to_le_bytes()); + for var in vs { match var { Json::Obj(f) => { let f: Vec<&(String, Json)> = f.iter().filter(|(k, _)| k != "h").collect(); h.update(b"{"); h.update(&(f.len() as u64).to_le_bytes()); + for (k, x) in f { hash_str(&mut h, k); hash_json(&mut h, x); } } x => hash_json(&mut h, x) } } } + _ => hash_json(&mut h, x) } } } + h.finish() +} +/// A program's key from A_t's skeleton and its field (each number by its bits, -0 as 0) +fn field_key(skel: &[u8; 32], field: &[Vec]) -> [u8; 32] { + let mut h = Sha256::new(); h.update(skel); + for row in field { h.update(&(row.len() as u64).to_le_bytes()); for x in row { h.update(&(if *x == 0.0 { 0 } else { x.to_bits() }).to_le_bytes()); } } + h.finish() +} /// The memory after an event: every count decays by 2^(-1/half_life), the event's combo gains one and keeps its inputs as its /// member; the learned normal (after the cold start) reads ln(1 + the logged pred) with the same half-life pub fn expect_step(p: &Persona, mem: &mut ExpectMem, raw: &Json, logged_pred: Option) { diff --git a/probbit-persona/src/run.rs b/probbit-persona/src/run.rs index fb13675..15abd4c 100644 --- a/probbit-persona/src/run.rs +++ b/probbit-persona/src/run.rs @@ -22,7 +22,34 @@ pub struct Prog { pub m: Model, pub vars: Vec, pub values: Vec, /// JSON (probbit_ir 1) -> Model. See docs/probbit-ir-json.md; strict: every field type-checked, unknown fields, duplicate ids / value /// names and empty domains rejected, with a structured error (its "Input contract"). -pub fn from_json(j: &Json) -> Result { +pub fn from_json(j: &Json) -> Result { from_json_parts(j)?.into_prog() } +/// A program read and lowered, before the compile pass (`compile_parts`) and the model: what `from_json` builds the model from. +/// RFC 0009 (the expectation's members): programs that differ only in their vars' `h` share these, so a host reads the text once +/// and builds each model with its own `h` (`Parts::model_with`). +#[derive(Clone)] +pub struct Parts { pub vars: Vec, pub values: Vec, h: Vec, allowed: Vec, clamp: Vec>, pairs: Vec, caps: Vec, start: Option> } +impl Parts { + /// The program's own `h` (n x k), as its text gives it + pub fn h(&self) -> &[f64] { &self.h } + /// The program's own model: the compile pass and `Model::new` on its `h`, as `from_json` + pub fn into_prog(self) -> Result { + let Parts { vars, values, mut h, allowed, clamp, pairs, caps, start } = self; let (n, k) = (vars.len(), values.len()); + let (pairs, caps, compiled) = compile_parts(k, &mut h, pairs, caps); + let mut m = Model::new(n, k, h, allowed, clamp, pairs, caps).map_err(|e| crate::json::value("", e))?; m.start = start; + Ok(Prog { m, vars, values, compiled }) + } + /// The model of the same program with `h` (n x k, by variable then value index) in place of its own: each weight checked as + /// `json::weight` checks a weight of the text (a program whose text carries such a weight is refused), then the compile pass + /// and `Model::new` as `from_json` runs them + pub fn model_with(&self, mut h: Vec) -> Result { + if h.len() != self.h.len() || h.iter().any(|x| !x.is_finite() || x.abs() > crate::json::MAX_WEIGHT) { return Err(crate::json::value("vars", "a weight beyond the finite weight limit")); } + let (pairs, caps, _) = compile_parts(self.values.len(), &mut h, self.pairs.clone(), self.caps.clone()); + let mut m = Model::new(self.vars.len(), self.values.len(), h, self.allowed.clone(), self.clamp.clone(), pairs, caps).map_err(|e| crate::json::value("", e))?; m.start = self.start.clone(); + Ok(m) + } +} +/// `from_json` up to the compile pass +pub fn from_json_parts(j: &Json) -> Result { use crate::json::{arr, at, count, fields, ix, keyed, limit, names, number, opt, req, schema, text, value, weight}; fields(j, "", &["probbit_ir", "values", "vars", "pairs", "caps", "all_different", "implies", "tables", "precedes", "linear", "start", "comment"])?; if let Some(c) = opt(j, "comment") { text(c, "comment")?; } @@ -185,10 +212,34 @@ pub fn from_json(j: &Json) -> Result { for (c, cp) in caps.iter().enumerate() { let load: u128 = cp.members.iter().enumerate().filter(|&(_, &(i, v))| x[i] == v).map(|(t, _)| cp.w(t) as u128).sum(); if load > cp.limit as u128 { return Err(value("start", format!("infeasible: lowered cap #{c} holds {load} > limit {}", cp.limit))); } } Some(x) } }; - let (pairs, caps, compiled) = compile_parts(k, &mut h, pairs, caps); - let mut m = Model::new(n, k, h, allowed, clamp, pairs, caps).map_err(|e| value("", e))?; m.start = start; drop(vindex); // no_std: hashbrown's map drop is not `may_dangle`, so the borrow of `values` ends here, not at the scope end - Ok(Prog { m, vars, values, compiled }) + Ok(Parts { vars, values, h, allowed, clamp, pairs, caps, start }) +} + +/// RFC 0009 (the expectation's members): the marginals `run` would put in a `decide` answer of `persona::run_program` (no +/// deadline, no --exact-ms, frontier states `FRONTIER_MAX_STATES`, `sweeps` of fixed work) when its first exact tier answers it: +/// over `exact_limit` raw plans the components tier (`exact_components_until(m, limit, FRONTIER_MAX_STATES, None)`), within it +/// the whole-program enumeration (`exact_within(m, 5, limit, None, None)`). Some(Some(marginals, n x k)) for an exact verdict +/// whose numbers are all finite (what `run_program`'s finite check reads: the plan's log weight, log Z, the top plans' +/// probabilities, the marginals of the allowed values); Some(None) for an answer without an exact verdict (infeasible, or a number +/// not finite); None when `run` would go on to another tier (that one declining), or would stop its exact tiers on the wall +/// clock (`sweeps` 0 over the limit): the caller runs the program. +pub fn enumerated(m: &mut Model, exact_limit: u64, sweeps: usize) -> Option>> { + m.collective = true; m.cluster = true; m.cycles = true; // as `persona::run_program` sets them before `run` + let space: f64 = (0..m.n).map(|i| m.cand_count(i) as f64).product(); + let fin = |m: &Model, marg: &[f64]| (0..m.n * m.k).all(|q| !m.allowed[q] || marg[q].is_finite()); + if space > exact_limit as f64 { + if sweeps == 0 { return None; } + let c = exact_components_until(m, exact_limit, FRONTIER_MAX_STATES, None)?; + if c.infeasible { return Some(None); } + let ok = c.map_logw.is_finite() && c.logz.is_finite() && (c.map_logw - c.logz).exp().is_finite() && fin(m, &c.marg); + return Some(ok.then_some(c.marg)); + } + let e = exact_within(m, 5, exact_limit, None, None).0?; + if e.n_feasible == 0 { return Some(None); } + let lw = m.logw(&e.top[0].1); + let ok = lw.is_finite() && e.logz.is_finite() && e.top.iter().all(|(p, _)| p.is_finite()) && fin(m, &e.marg); + Some(ok.then_some(e.marg)) } /// R19.6 (P2.1): `phases` on every `probbit run` answer, `[{"phase": name, "ms": wall}]` in order: parse (stdin JSON), compile diff --git a/probbit-persona/src/trace.rs b/probbit-persona/src/trace.rs index 66d87fd..9617ca9 100644 --- a/probbit-persona/src/trace.rs +++ b/probbit-persona/src/trace.rs @@ -38,9 +38,10 @@ pub const NAMES: [&str; PHASES] = ["input", "expect", "prep", "compile", "solve" /// One event's breakdown: per phase (indexed by `Phase as usize`) the microseconds and the largest probe value; the event's /// total microseconds and the engine calls of the turn; RFC 0009: the expectation's programs answered from its cache -/// (`expect_cached`) and solved (`expect_solved`) +/// (`expect_cached`) and solved (`expect_solved`), and the part of `expect` spent building the members' programs +/// (`expect_build_us`) and solving them (`expect_solve_us`, cache lookups included) #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct TurnTrace { pub us: [u64; PHASES], pub bytes: [u64; PHASES], pub total_us: u64, pub solves: u32, pub expect_cached: u32, pub expect_solved: u32 } +pub struct TurnTrace { pub us: [u64; PHASES], pub bytes: [u64; PHASES], pub total_us: u64, pub solves: u32, pub expect_cached: u32, pub expect_solved: u32, pub expect_build_us: u64, pub expect_solve_us: u64 } /// A resume's walk (`strand::resume_from_record`, `strand::resume_boxed_from`): the lines read after its start, the event lines /// applied to a record that was behind (each one turn without the expectation's member solves) and their microseconds, and the @@ -80,6 +81,7 @@ mod on { pub(crate) struct Stopwatch(Instant); impl Stopwatch { pub(crate) fn start() -> Stopwatch { Stopwatch(Instant::now()) } pub(crate) fn us(&self) -> u64 { us(self.0) } } pub(crate) fn expect_cache(hits: u32, solves: u32) { T.with(|t| { let mut g = t.borrow_mut(); if g.open.is_some() { g.acc.expect_cached += hits; g.acc.expect_solved += solves; } }) } + pub(crate) fn expect_split(build: u64, solve: u64) { T.with(|t| { let mut g = t.borrow_mut(); if g.open.is_some() { g.acc.expect_build_us += build; g.acc.expect_solve_us += solve; } }) } pub(crate) fn end() { let cur = T.with(|t| t.borrow().cur); enter(cur); T.with(|t| { let mut g = t.borrow_mut(); if let Some((t0, _)) = g.open.take() { g.acc.total_us = us(t0); g.last = g.acc; } }) @@ -88,7 +90,7 @@ mod on { #[cfg(feature = "turn_trace")] pub use on::{last, last_resume, set_probe}; #[cfg(feature = "turn_trace")] -pub(crate) use on::{begin, end, enter, expect_cache, resumed, Stopwatch}; +pub(crate) use on::{begin, end, enter, expect_cache, expect_split, resumed, Stopwatch}; #[cfg(not(feature = "turn_trace"))] #[inline(always)] pub(crate) fn begin() {} @@ -103,6 +105,9 @@ pub(crate) fn end() {} pub(crate) fn expect_cache(_hits: u32, _solves: u32) {} #[cfg(not(feature = "turn_trace"))] #[inline(always)] +pub(crate) fn expect_split(_build: u64, _solve: u64) {} +#[cfg(not(feature = "turn_trace"))] +#[inline(always)] pub(crate) fn resumed(_t: ResumeTrace) {} #[cfg(not(feature = "turn_trace"))] pub(crate) struct Stopwatch; @@ -110,12 +115,14 @@ pub(crate) struct Stopwatch; impl Stopwatch { #[inline(always)] pub(crate) fn start() -> Stopwatch { Stopwatch } #[inline(always)] pub(crate) fn us(&self) -> u64 { 0 } } impl TurnTrace { - /// As JSON: `{"us": {phase: µs}, "bytes": {phase: value}, "total_us": n, "solves": n, "expect_cached": n, "expect_solved": n}` + /// As JSON: `{"us": {phase: µs}, "bytes": {phase: value}, "total_us": n, "solves": n, "expect_cached": n, "expect_solved": n, + /// "expect_build_us": n, "expect_solve_us": n}` /// (phases in `Phase` order) pub fn to_json(&self) -> crate::json::Json { use crate::json::Json; let row = |v: &[u64; PHASES]| Json::Obj(NAMES.iter().zip(v).map(|(k, x)| (k.to_string(), Json::Num(*x as f64))).collect()); Json::Obj(vec![("us".into(), row(&self.us)), ("bytes".into(), row(&self.bytes)), ("total_us".into(), Json::Num(self.total_us as f64)), ("solves".into(), Json::Num(self.solves as f64)), - ("expect_cached".into(), Json::Num(self.expect_cached as f64)), ("expect_solved".into(), Json::Num(self.expect_solved as f64))]) + ("expect_cached".into(), Json::Num(self.expect_cached as f64)), ("expect_solved".into(), Json::Num(self.expect_solved as f64)), + ("expect_build_us".into(), Json::Num(self.expect_build_us as f64)), ("expect_solve_us".into(), Json::Num(self.expect_solve_us as f64))]) } } From bbb8b9b4fe92049d8287b0968c15f4f684cda0d0 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 09:30:01 -0700 Subject: [PATCH 25/61] probbit-persona: the members' frames: the compiler writes its text before its meta again (emit inlined; a member's text in its own frame), from_json one frame (its parts and model inlined), the members' loop in the deviation's frame and the mixture out of it (proxy, body persona: turn stack 13,824 B, 13,840 before; heap peak unchanged); docs: the embedding guide's "The expectation's members: one program text per event" (the split measured, the model path, the bytes, the proxy's numbers); CHANGELOG --- CHANGELOG.md | 7 +++++ docs/embedding.md | 43 ++++++++++++++++++++++++++++++- probbit-persona/src/persona.rs | 47 ++++++++++++++++++++++------------ probbit-persona/src/run.rs | 4 ++- 4 files changed, 83 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eda9fd8..15afa2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,13 @@ All notable changes to this project are documented here. The format follows persona. `strand::resume_boxed_from(header, checkpoint, line, trailing, &p, state, clock, &engine)` resumes from the header, the last checkpoint line and the lines after it read one at a time, never the whole strand: cost O(lines since the checkpoint). `turn_trace`: `trace::last_resume()` (lines walked, lines applied, their µs). No byte of any strand changes. +- **The expectation's members as models** (docs/embedding.md "The expectation's members: one program text per event"): with + the cache on, the event's own program is the only text the deviation builds and reads; every other member is compiled + without its text and, when the habits in force are the event's, solved as the event's model with its own `h` + (`run::from_json_parts`, `run::Parts::model_with`, `run::enumerated`: the tier `run` answers with, the same calls); another + tier or other rules: its text, written from its compile meta, run by the engine. Same marginals, so every stance, strand line + and digest is the same as the reference path (`ExpectCache::new(false)`); proxy, the body persona's recorded day: `expect` + 74.6% → 67.7% of the turn. `turn_trace` gains `expect_build_us` and `expect_solve_us` (the members' builds and solves). - **The expectation's cache** (docs/embedding.md "The expectation's cache"): `Live` keeps the exact marginals of the programs the deviation solved, keyed by the program without its comment, so an event whose members' programs are the previous event's solves none of them again (a life of one repeated event: `expect` 52% → 37% of the turn on the proxy, 95% diff --git a/docs/embedding.md b/docs/embedding.md index 8d799a6..50aa5d2 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -150,7 +150,7 @@ the stance, its `deviation`, every strand line and digest are the same with the After each event only the rows that event used are kept: at most `max_combos + 1`. Decay never invalidates a row (marginals do not read counts); a row stops being used when its program changes, which happens whenever the state the program is built from moves (moods, needs, senses: every event of most lives; the state's digest itself carries the turn, so it is never a -key). The members' programs are still built every event: the cache skips solves, not compiles. A life whose programs move +key). The cache skips solves, not builds (the next section removes most of the build). A life whose programs move every event gets no hit; after an event without one the cache is not consulted for the next 1, 2, 4, ... 32 events (a hit resets the pause), so such a life keys two events in 34. `turn_trace` counts `expect_cached` and `expect_solved` per event. @@ -164,6 +164,47 @@ target. A host short of heap at a large `max_combos` turns the cache off; the by persona's turn on the proxy takes 13,840 B of stack (13,808 before) and its heap peak is 89,990 B (88,153 before: the rows the individual holds between events). +### The expectation's members: one program text per event + +Measured before (proxy, the body persona's recorded day, 134 events, max_combos 6; `turn_trace`'s `expect_build_us` and +`expect_solve_us`): 6.63 programs per event, each about 105 µs to build (prep 40, compile 61) and 110 µs to solve (`from_json` +47, the tier ladder with its answer document 36.5, the document's finite check 10, the cache key's hash over the text). The +exact solve itself is the small part; the program as text and back is about half of every member. + +A member's program is the event's own program A_t with another `h`: the persona's values, variables, pairs and steps are the +same, and so are the rules whenever the habits in force are A_t's. So, with the cache on (`Live`'s default): + +- A_t is built as before (its text and the compiler's meta) and read once into the model's parts before the compile pass + (`run::from_json_parts`), on the first program the cache does not answer. +- Every other member (the empty program, each remembered combination) runs prep, credit and the gate, then the compiler's + arithmetic without writing its text (the field per variable and the rules in force). With A_t's rules, its model is A_t's + parts with its own `h` (each level at the value index its name has in the text, zeros where the text omits a weight, the + twins copied, each weight checked as the text's is), then the compile pass and `Model::new` as `from_json` runs them + (`run::Parts::model_with`): the model the engine would build from its text. A_t's own field mapped this way is checked + against the `h` its text carries before any member uses the mapping. +- The model is solved by the exact tier `run` answers a `decide` with (`run::enumerated`): over `exact_limit` raw plans the + components tier, within it the whole-program enumeration, the same calls with the same arguments, the same verdicts, the + same finite check; the marginals are read as from the answer document. Another tier (one of those declining, or `sweeps: 0` + over the limit, where `run` stops on the wall clock), or other rules in force: the member's text is written from its meta + (no second build) and run by the engine, as before. +- The cache keys these programs by A_t's text without its comment and its vars' `h`, then the program's field: equal keys, + equal texts, as the cache's own key. + +No member's text, digest or marginals is in a stance or a strand line (only the deviation's numbers, from the marginals), and +the marginals are the same numbers: every stance, strand line and digest is the same as with the cache off, which is the +reference path (`ExpectCache::new(false)`, `persona::deviation`: every member's text built and run by the engine). The engine +argument is not called for these members; the deviation already reads members as the engine's exact tier (the cache returns +rows without calling it), and every engine in the repository is `persona::run_program`. + +On the proxy (median of 3–5 runs, before → after, `expect`'s share of the turn, cache on): the body persona's recorded day +74.6% → 67.7% (`expect` 1,467 → 1,047 µs per event; per member build 105 → 84 µs, solve 110 → 65 µs); its two recorded events +50.6% → 41.8%; the fixture persona, one event repeated 500 times, 37.1% → 35.2%; 500 events of many combinations 81.6% → +81.9% (most of its members have other habits in force than A_t: they keep the engine's path, without a second build). What is +left per member on the body is its prep (about 40 µs: the senses, needs and drives step on the member's own readings) and the +compiler's arithmetic, then the components tier; with M members per event this is the floor of any exact path that solves +every member. The body persona's turn on the proxy takes 13,824 B of stack (13,840 before) and its heap peak is unchanged +(89,990 B, at the turn's solve; inside `expect` the peak rises from 68,476 to 87,214 B: A_t's parts and one member's model). + Silicon numbers (the same calls on the target's engine thread) are measured by the firmware and will follow; the x86_64 proxy and an Xtensa build differ in frame layout, so read the proxy as the shape and the ratio, not the target's bytes. diff --git a/probbit-persona/src/persona.rs b/probbit-persona/src/persona.rs index a0e4407..a66800c 100644 --- a/probbit-persona/src/persona.rs +++ b/probbit-persona/src/persona.rs @@ -2584,20 +2584,24 @@ fn compile(p: &Persona, st: &State, raw: &[(String, Json)], o: &Opts) -> R<(Json if l.iter().any(|x| *x != 0.0) { contrib[i].push(("floor".into(), l.clone())); field[i] = total(&contrib[i], l.len()); } lift = d.goals.iter().zip(&l).filter(|(_, x)| **x != 0.0).map(|(g, x)| (g.id.clone(), *x)).collect(); } } let use_twin = o.twin.unwrap_or(p.eng.twin); + let ids: Vec = active.iter().map(|h| h.id.clone()).collect(); + // the text before the meta, as the compiler always wrote it (the meta's fields move in after) + let prog = if o.meta_only { Json::Null } else { emit(p, &field, &rules, &ids, (st.seed, st.turn), use_twin, &sem, &o.clamps) }; 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, new_learned, contrib, field, values: sem, twin: use_twin, lift, learn_sign, + let meta = Meta { vals, hist, ignored, active: ids, conditional, active_inputs, rules, owners, new_mood, new_learned, contrib, field, values: sem, twin: use_twin, lift, learn_sign, seed: st.seed, turn: st.turn }; - if o.meta_only { return Ok((Json::Null, meta)); } - Ok((emit(p, &meta, &o.clamps), meta)) + Ok((prog, meta)) } /// The program's text from the compiler's meta: values, vars (traits and moods, then the agenda steps) with their `h` from the -/// field, pairs, the rules in force (renamed), the comment (persona, seed, turn, active habits); positional = level i -> l -#[inline(never)] -fn emit(p: &Persona, meta: &Meta, clamps: &[(String, String)]) -> Json { - let (nv, ns) = (p.vars.len(), p.steps.len()); let (field, rules, use_twin) = (&meta.field, &meta.rules, meta.twin); +/// field, pairs, the rules in force (renamed), the comment (persona, seed, turn, active habits); positional = level i -> l. +/// Inlined in `compile` (its frame as before); a member's text is written in a frame of its own (`member_text`) +#[inline(always)] +#[allow(clippy::too_many_arguments)] +fn emit(p: &Persona, field: &[Vec], rules: &[Vec], active: &[String], (seed, turn): (u64, u64), use_twin: bool, sem: &[String], clamps: &[(String, String)]) -> Json { + let (nv, ns) = (p.vars.len(), p.steps.len()); let pos_vals = p.eng.positional; 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 { meta.values.clone() }; + let values: Vec = if pos_vals { (0..lmax).map(|i| format!("l{i}")).chain(p.slots.iter().cloned()).collect() } else { sem.to_vec() }; 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 mut pv = vec![]; @@ -2617,8 +2621,8 @@ fn emit(p: &Persona, meta: &Meta, clamps: &[(String, String)]) -> Json { 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, meta.seed, meta.turn, - if meta.active.is_empty() { "none".to_string() } else { meta.active.join(", ") }); + let comment = format!("probbit persona {} {} seed {} turn {}; active habits: {}", p.name, p.version, seed, turn, + if active.is_empty() { "none".to_string() } else { active.join(", ") }); 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())); } } @@ -4128,12 +4132,19 @@ fn deviation_in(p: &Persona, st: &State, raw: &Json, mem: &ExpectMem, eng: Engin // with this event's hours), in the mixture's order let mut ins = vec![with_elapsed(&Json::Obj(vec![]), &e)]; for (k, _) in &ws { if *k == key || k == "{}" { continue; } ins.push(with_elapsed(&mem.combos.iter().find(|x| x.0 == *k).map(|x| x.2.clone()).unwrap_or(Json::Obj(vec![])), &e)); } - let mut ms = members_marg(p, st, raw, &ins, eng, cache)?.into_iter(); + let ms = members_marg(p, st, raw, &ins, eng, cache)?; + mixture(mem, &ws, &key, member, ms) +} +/// The deviation from the programs' marginals (A_t, E_t, then the other members in the mixture's order), out of the frame the +/// members are built under +#[inline(never)] +fn mixture(mem: &ExpectMem, ws: &[(String, f64)], key: &str, member: bool, ms: Vec>) -> Option { + let mut ms = ms.into_iter(); let a = ms.next()??; let ed = ms.next()??; let cold = !mem.warm && key == "{}"; // the members' marginals, weighted; a member that does not compile or solve exactly folds into the empty member let mut parts: Vec<(f64, Marg)> = vec![]; let mut empty_w = 0.0; - for (k, w) in &ws { + for (k, w) in ws { if *k == key { parts.push((*w, a.clone())); continue; } if k == "{}" { empty_w += w; continue; } match ms.next().flatten() { Some(m) => parts.push((*w, m)), None => empty_w += w } } @@ -4146,6 +4157,9 @@ fn deviation_in(p: &Persona, st: &State, raw: &Json, mem: &ExpectMem, eng: Engin let z = (!cold).then(|| ((1.0 + sig5(pred)).ln() - mem.mean) / (mem.var + DEV_VAR_FLOOR).sqrt()); Some(Deviation { s_self, pred, input: -w_cur.ln(), cold, member, z, members: ws.len(), terms: a.iter().zip(tp.iter().zip(&ts)).map(|((id, _), (x, y))| (id.clone(), *x, *y)).collect() }) } +/// A member's program text from its compile meta (no clamps), in a frame of its own +#[inline(never)] +fn member_text(p: &Persona, mm: &Meta) -> Json { emit(p, &mm.field, &mm.rules, &mm.active, (mm.seed, mm.turn), mm.twin, &mm.values, &[]) } /// A program's exact marginals the reference way: its text built (`program_json`) and run by the engine (`ExpectCache::marg`); /// turn_trace: the build and the solve timed apart (`expect_build_us`, `expect_solve_us`) #[inline(never)] @@ -4162,8 +4176,9 @@ struct Skel { parts: crate::run::Parts, idx: Vec> } /// (`Opts::meta_only`) and, when its rules in force are A_t's, solved as A_t's model with its own `h` by the enumeration /// `run_program` would answer with (`run::enumerated`): the same model, the same marginals, bit for bit. Any other program /// (other rules, another tier) is built and run as the reference does. The cache's key for these programs: A_t's text without -/// `comment` and the vars' `h` (its skeleton), then the program's field: equal keys, equal texts. -#[inline(never)] +/// `comment` and the vars' `h` (its skeleton), then the program's field: equal keys, equal texts. Inlined in the deviation's frame +/// (each member's build runs right under it, as before) +#[inline(always)] fn members_marg(p: &Persona, st: &State, raw: &Json, ins: &[Json], eng: Engine, cache: &mut ExpectCache) -> Option>> { let mut out = Vec::with_capacity(ins.len() + 1); if !cache.on { out.push(text_marg(p, st, raw, eng, cache)); if out[0].is_none() { return None; } @@ -4183,9 +4198,9 @@ fn members_marg(p: &Persona, st: &State, raw: &Json, ins: &[Json], eng: Engine, Ok((_, mm)) if mm.rules == meta.rules => { let (b, t) = (t.us(), crate::trace::Stopwatch::start()); let r = model_marg(p, st, &prog, &mm, skel.as_ref(), &mut sk, false, eng, cache); // another tier (or no usable skeleton): its text from its meta, run by the engine - let r = match r { Some(r) => r, None => { let prog = emit(p, &mm, &[]); cache.marg(p, st, &prog, eng) } }; crate::trace::expect_split(b, t.us()); r } + let r = match r { Some(r) => r, None => { let prog = member_text(p, &mm); cache.marg(p, st, &prog, eng) } }; crate::trace::expect_split(b, t.us()); r } // other rules in force: its text from its meta (no second build), run by the engine - Ok((_, mm)) => { let prog = emit(p, &mm, &[]); let (b, t) = (t.us(), crate::trace::Stopwatch::start()); let m = cache.marg(p, st, &prog, eng); crate::trace::expect_split(b, t.us()); m } }; + Ok((_, mm)) => { let prog = member_text(p, &mm); let (b, t) = (t.us(), crate::trace::Stopwatch::start()); let m = cache.marg(p, st, &prog, eng); crate::trace::expect_split(b, t.us()); m } }; if i == 0 && m.is_none() { return None; } out.push(m); } Some(out) diff --git a/probbit-persona/src/run.rs b/probbit-persona/src/run.rs index 15abd4c..fca7c67 100644 --- a/probbit-persona/src/run.rs +++ b/probbit-persona/src/run.rs @@ -32,6 +32,7 @@ impl Parts { /// The program's own `h` (n x k), as its text gives it pub fn h(&self) -> &[f64] { &self.h } /// The program's own model: the compile pass and `Model::new` on its `h`, as `from_json` + #[inline(always)] pub fn into_prog(self) -> Result { let Parts { vars, values, mut h, allowed, clamp, pairs, caps, start } = self; let (n, k) = (vars.len(), values.len()); let (pairs, caps, compiled) = compile_parts(k, &mut h, pairs, caps); @@ -48,7 +49,8 @@ impl Parts { Ok(m) } } -/// `from_json` up to the compile pass +/// `from_json` up to the compile pass (inlined: `from_json` keeps one frame) +#[inline(always)] pub fn from_json_parts(j: &Json) -> Result { use crate::json::{arr, at, count, fields, ix, keyed, limit, names, number, opt, req, schema, text, value, weight}; fields(j, "", &["probbit_ir", "values", "vars", "pairs", "caps", "all_different", "implies", "tables", "precedes", "linear", "start", "comment"])?; From a8750f190d73824409f070235283c46f7dc97f11 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 16:22:35 -0700 Subject: [PATCH 26/61] probbit-persona: A_t solved once: Live::event runs the turn first and, with the cache on, the turn hands the deviation its first program's meta and exact marginals (turn_at: no rest due, src taken out alike, op decide; Some(None) when that answer is not exact) so the event's own program is neither built nor solved a second time (its text written again from the meta for the members' skeleton; its row kept in the cache); ExpectCache::with_reuse (reuse off: A_t built and solved by the deviation, as before; check: the handed A_t compared with a second build and solve, text for text, bit for bit); test: the turn's own program as A_t moves no byte (the sensed persona, 500 events, max_combos 6 / 16, engines 0.13.0 / 0.14.0; the expectation fixture persona) --- probbit-cli/src/live.rs | 33 +++++++++- probbit-persona/src/persona.rs | 111 ++++++++++++++++++++++++++------- probbit-persona/src/strand.rs | 8 ++- 3 files changed, 124 insertions(+), 28 deletions(-) diff --git a/probbit-cli/src/live.rs b/probbit-cli/src/live.rs index c0108dd..147b387 100644 --- a/probbit-cli/src/live.rs +++ b/probbit-cli/src/live.rs @@ -515,7 +515,8 @@ mod tests { for (d, n, engine, kind, fallback) in &cases { let p = persona::build(d).unwrap_or_else(|e| panic!("{}: {}", e.path, e.msg)); let ev = |t: usize| if *kind == 0 { sensed(t) } else { s1_event(t) }; let life = |on: bool| { let (mut lv, head) = Live::start(p.clone(), d, persona::init(&p, Some(2), true, &run), Clock::Fixed, engine); - lv.expect_cache = persona::ExpectCache::new(on); let (mut text, mut docs, mut modeled, mut texted) = (format!("{head}\n"), vec![], 0u32, 0u32); + // A_t built and solved by the deviation (as T3 measured it): the turn's own program is the T3-b test's + lv.expect_cache = persona::ExpectCache::new(on).with_reuse(false, false); let (mut text, mut docs, mut modeled, mut texted) = (format!("{head}\n"), vec![], 0u32, 0u32); for t in 0..*n { let (s, l) = lv.event(&ev(t), &run).unwrap_or_else(|e| panic!("event {t}: {}: {}", e.path, e.msg)); text += &l; text.push('\n'); docs.push(persona::canon(&s)); let (m, (_, sv)) = (lv.expect_cache.modeled(), lv.expect_cache.last()); modeled += m; texted += sv - m; if lv.n % 10 == 0 { text += &lv.checkpoint(&s); text.push('\n'); } } (text, docs, lv.st.digest.clone(), modeled, texted) }; @@ -526,6 +527,36 @@ mod tests { assert_eq!(verify(&on.0, &run).unwrap().get("events"), Some(&Json::Num(*n as f64)), "{ctx}"); } } + /// T3-b: the deviation's A_t read from the turn moves no byte. The same lives run with the cache on, the turn's first program + /// handed to the deviation (`turn_at`: its meta and exact marginals; A_t checked against a second build and solve on every + /// event, text for text and bit for bit) and not handed (A_t built and solved by the deviation, as before): every stance + /// document, every strand line (a checkpoint every 10) and the final state are the same, and A_t is read from the turn. The + /// sensed persona (floats that move every event, two needs, drives, a habit on a flag), 500 events, max_combos 6 and 16, + /// engines 0.13.0 and 0.14.0; the expectation fixture persona, 500 varied events, max_combos 16 + #[test] + fn the_turns_own_program_as_a_t_moves_no_byte() { + let load = |rel: &str| probbit_persona::yaml::load(&std::fs::read_to_string(format!("{}/../{rel}", env!("CARGO_MANIFEST_DIR"))).unwrap()).unwrap_or_else(|e| panic!("{}", e.msg)); + let sensed = |t: usize| { let h = (t as u64).wrapping_mul(2654435761) >> 7; let b = |k: u64| (h >> k) & 1 == 1; + let needs = if b(9) && b(10) { r#","needs":{"context":0.2}"# } else { "" }; + json::parse(&format!(r#"{{"quiet":{},"senses":{{"tokens":{}}},"src":"env:host","elapsed_hours":{}{needs}}}"#, b(2) && b(5), ((h >> 11) % 997) as f64 / 1000.0, [0.1, 0.5, 1.0][((h >> 15) % 3) as usize])).unwrap() }; + let mut cases: Vec<(Json, &str, usize)> = vec![]; + for mc in [6usize, 16] { for engine in ["probbit 0.13.0", "probbit 0.14.0"] { let mut d = load("bench/body/fixtures/needs2.yaml"); + put(&mut d, &["expectation"], json::parse(&format!(r#"{{"max_combos":{mc}}}"#)).unwrap()); cases.push((d, engine, 0)); } } + let mut d = load("probbit-cli/tests/fixtures/monitor/tutor-expect.yaml"); put(&mut d, &["expectation"], json::parse(r#"{"max_combos":16}"#).unwrap()); cases.push((d, "probbit 0.14.0", 1)); + for (d, engine, kind) in &cases { let p = persona::build(d).unwrap_or_else(|e| panic!("{}: {}", e.path, e.msg)); + let ev = |t: usize| if *kind == 0 { sensed(t) } else { s1_event(t) }; + let life = |reuse: bool| { let (mut lv, head) = Live::start(p.clone(), d, persona::init(&p, Some(2), true, &run), Clock::Fixed, engine); + lv.expect_cache = persona::ExpectCache::new(true).with_reuse(reuse, reuse); assert_eq!(lv.expect_cache.reuses(), reuse); + let (mut text, mut docs, mut reused) = (format!("{head}\n"), vec![], 0u32); + for t in 0..500 { let (s, l) = lv.event(&ev(t), &run).unwrap_or_else(|e| panic!("event {t}: {}: {}", e.path, e.msg)); text += &l; text.push('\n'); docs.push(persona::canon(&s)); + reused += lv.expect_cache.reused(); if lv.n % 10 == 0 { text += &lv.checkpoint(&s); text.push('\n'); } } + (text, docs, lv.st.digest.clone(), reused) }; + let (on, off) = (life(true), life(false)); let ctx = format!("{engine}, {}", persona::canon(d.get("expectation").unwrap())); + assert_eq!(on.1, off.1, "every stance document ({ctx})"); assert_eq!(on.0, off.0, "every strand byte ({ctx})"); assert_eq!(on.2, off.2, "the final state ({ctx})"); + assert!(on.3 > 0 && off.3 == 0, "A_t read from the turn: on {} off {} ({ctx})", on.3, off.3); + assert_eq!(verify(&on.0, &run).unwrap().get("events"), Some(&Json::Num(500.0)), "{ctx}"); } + } + /// S2 (docs/persona.md §5.7, "Resume from a record"): a life recorded unbroken, and the same life resumed by each path: the /// boxed replay (every stance document and the individual it reaches), a record 1, 5 and 49 lines behind the strand /// (`resume_from_record`: the trailing event lines applied, not solved), and the embedded resume from the header, each diff --git a/probbit-persona/src/persona.rs b/probbit-persona/src/persona.rs index a66800c..1012532 100644 --- a/probbit-persona/src/persona.rs +++ b/probbit-persona/src/persona.rs @@ -2493,6 +2493,11 @@ struct Opts { no_inertia: bool, clamps: Vec<(String, String)>, twin: Option Box { + Box::new(Meta { vals: vec![], hist: vec![], ignored: vec![], conditional: vec![], active_inputs: vec![], owners: vec![], new_mood: vec![], new_learned: Default::default(), contrib: vec![], lift: vec![], ..m }) +} /// 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)> { @@ -2881,14 +2886,31 @@ pub fn turn_boxed(p: &Persona, st: &State, raw: &Json, no_inertia: bool, eng: En pub fn turn_any_source(p: &Persona, st: &State, raw: &Json, no_inertia: bool, eng: Engine, timing: bool) -> R<(Json, State)> { turn_src(p, st, raw, no_inertia, eng, timing, false).map(|(j, s)| (j, *s)) } /// A turn's inputs and state after the steps before the compiler (`turn_prep`): the stepped state, the compiler's inputs, the /// inputs as given, the event's `src`, its payments and what they settled -struct Prepped { st: Box, raw: Vec<(String, Json)>, given: Vec<(String, Json)>, src: Option, pay: Option, settled: Option } -/// The engine's answer read back (`turn_solve`): the stance under construction and the next state -struct Solved { out: Box, ns: Box } +struct Prepped { st: Box, raw: Vec<(String, Json)>, given: Vec<(String, Json)>, src: Option, pay: Option, settled: Option, fresh: bool } +/// The engine's answer read back (`turn_solve`): the stance under construction, the next state and, when asked for, what the +/// expectation reads of the turn's first program (`At`; None inside: that answer was not exact) +struct Solved { out: Box, ns: Box, at: Option>> } +/// RFC 0009: the turn's first program as the deviation's A_t (the same program: `turn_at`): its exact marginals read as +/// `exact_marg` reads them and its compile meta (the fields the members read), so A_t is neither built nor solved again +pub struct At { marg: Marg, meta: Box } #[inline(never)] fn turn_src(p: &Persona, st: &State, raw: &Json, no_inertia: bool, eng: Engine, timing: bool, check: bool) -> R<(Json, Box)> { + turn_src_at(p, st, raw, no_inertia, eng, timing, check, false).map(|(j, s, _)| (j, s)) +} +/// `turn_boxed` (no inertia off, no timing, the provenance check on) that also hands the expectation its A_t when `want` +/// (`Live::event` with the cache on): Some when the turn's first program is the program the deviation would build for the +/// event (no rest due before it, `src` taken out alike, the persona's op `decide`), Some(None) inside when its answer is not +/// exact (then there is no deviation); None: the deviation builds and solves A_t itself +#[inline(never)] +pub fn turn_at(p: &Persona, st: &State, raw: &Json, eng: Engine, want: bool) -> R<(Json, Box, Option>>)> { turn_src_at(p, st, raw, false, eng, false, true, want) } +#[inline(always)] +#[allow(clippy::too_many_arguments)] +fn turn_src_at(p: &Persona, st: &State, raw: &Json, no_inertia: bool, eng: Engine, timing: bool, check: bool, want: bool) -> R<(Json, Box, Option>>)> { let pr = turn_prep(p, st, raw, check)?; - let sv = turn_solve(p, &pr, no_inertia, eng, timing)?; - Ok(turn_doc(p, pr, sv)) + // the deviation's A_t is `program_meta` of the same state and inputs: no rest, `src` taken out only with these blocks + let want = want && check && pr.fresh && !no_inertia && p.eng.op == "decide" && (p.reward.is_none() || p.senses.is_some() || p.credit().is_some() || p.has_needs()); + let mut sv = turn_solve(p, &pr, no_inertia, eng, timing, want)?; let at = sv.at.take(); + let (j, s) = turn_doc(p, pr, sv); Ok((j, s, at)) } /// The steps before the compiler: the inputs read, `src` and the payments taken out, a rest due applied, then the sense / needs / /// drive / pace steps, the credit step and the surprise gate, each on the heap @@ -2912,7 +2934,7 @@ fn turn_prep(p: &Persona, st: &State, raw: &Json, check: bool) -> R { let (st2, settled) = match &pay { Some(k) => { let (s, d) = credit_boxed(p, &st2, &raw, k, src.as_ref(), check)?; (s, Some(d)) } None => (st2, None) }; // RFC 0002 Design B: the surprise gate steps v, r̂, g and the counts of each paid learned trait before the learner reads g let st2 = gate_boxed(p, st2, &raw)?; - Ok(Prepped { st: st2, raw, given, src, pay, settled }) + Ok(Prepped { st: st2, raw, given, src, pay, settled, fresh: rested.is_none() }) } #[inline(never)] fn rest_boxed(p: &Persona, st: &State) -> Box { Box::new(rest_apply(p, st)) } @@ -2931,7 +2953,7 @@ fn settle_boxed(p: &Persona, j: &mut Json, ns: Box, k: &Pay, s: Settled) /// The compiler, the engine and the decoder: the program for the stepped state, its solve (a yield on a habit conflict, a second, /// clamped solve for the persona's vouch rule) and the answer read back #[inline(never)] -fn turn_solve(p: &Persona, pr: &Prepped, no_inertia: bool, eng: Engine, timing: bool) -> R { +fn turn_solve(p: &Persona, pr: &Prepped, no_inertia: bool, eng: Engine, timing: bool, want: bool) -> R { use crate::trace::{enter, Phase}; let (st, raw): (&State, &[(String, Json)]) = (&pr.st, &pr.raw); enter(Phase::Compile); @@ -2939,6 +2961,8 @@ fn turn_solve(p: &Persona, pr: &Prepped, no_inertia: bool, eng: Engine, timing: 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); + // the expectation's A_t: this answer's exact marginals (an exact answer never yields, so `meta` stays this program's) + let first = want.then(|| doc_marg(&doc)); 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()); @@ -2972,17 +2996,18 @@ fn turn_solve(p: &Persona, pr: &Prepped, no_inertia: bool, eng: Engine, timing: 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; out.lift = lift; + let at = first.map(|m| m.map(|marg| Box::new(At { marg, meta: slim(meta) }))); 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(Solved { out, ns }) + Ok(Solved { out, ns, at }) } /// The stance document from the decoded stance: its senses, drives and body blocks, `src` back in its inputs, the credit settled, /// the injected block and the glyph #[inline(never)] fn turn_doc(p: &Persona, pr: Prepped, sv: Solved) -> (Json, Box) { crate::trace::enter(crate::trace::Phase::Document); - let Prepped { st, given, src, pay, settled, .. } = pr; let st: &State = &st; let Solved { out, mut ns } = sv; + let Prepped { st, given, src, pay, settled, .. } = pr; let st: &State = &st; let Solved { out, mut ns, .. } = sv; let mut j = out.to_json(p, st); if let (Some(sn), Some(ss)) = (&p.senses, &st.senses) { stance_senses(&mut j, sn, ss, &given); } if let (Some(d), Some(ds)) = (&p.drives, &st.drives) { stance_drives(&mut j, d, ds, &out.lift, &given); } @@ -4019,7 +4044,10 @@ type Marg = Vec<(String, Vec<(String, f64)>)>; /// The non-twin exact marginals of a program -> None unless the exact tier solved it #[inline(never)] fn exact_marg(p: &Persona, st: &State, prog: &Json, eng: Engine) -> Option { - let doc = eng(prog, &Flags { op: "decide".into(), ..flags(p, st) }); + doc_marg(&eng(prog, &Flags { op: "decide".into(), ..flags(p, st) })) +} +/// An answer's non-twin marginals -> None unless its verdict is exact +fn doc_marg(doc: &Json) -> Option { if doc.get("verdict").and_then(Json::as_str) != Some("exact") { return None; } Some(doc.get("marginals")?.as_obj()?.iter().filter(|(k, _)| !k.starts_with("free.")).map(|(k, v)| (k.clone(), v.as_obj().map_or(vec![], |o| { let mut r: Vec<(String, f64)> = o.iter().map(|(l, x)| (l.clone(), x.as_f64().unwrap_or(0.0))).collect(); r.sort_by(|a, b| a.0.cmp(&b.0)); r }))).collect()) @@ -4036,11 +4064,21 @@ fn exact_marg(p: &Persona, st: &State, prog: &Json, eng: Engine) -> Option /// one the cache is not consulted for the next 1, 2, 4, ... 32 events (a hit resets it; the rows are dropped meanwhile and the event /// after the pause fills them for the one after it to compare), so such a life keys two events in 34. #[derive(Clone, Debug)] -pub struct ExpectCache { on: bool, rows: Vec<([u8; 32], Marg)>, used: Vec, hits: u32, solves: u32, skip: u32, back: u32, live: bool, had: bool, modeled: u32 } +pub struct ExpectCache { on: bool, rows: Vec<([u8; 32], Marg)>, used: Vec, hits: u32, solves: u32, skip: u32, back: u32, live: bool, had: bool, modeled: u32, + /// T3-b: A_t from the turn (`turn_at`); `check`: also built and solved again, and compared + reuse: bool, check: bool, reused: u32 } impl Default for ExpectCache { fn default() -> ExpectCache { ExpectCache::new(true) } } impl ExpectCache { /// An empty cache; `on: false` solves every program (the receipt is the same) - pub fn new(on: bool) -> ExpectCache { ExpectCache { on, rows: vec![], used: vec![], hits: 0, solves: 0, skip: 0, back: 0, live: false, had: false, modeled: 0 } } + pub fn new(on: bool) -> ExpectCache { ExpectCache { on, rows: vec![], used: vec![], hits: 0, solves: 0, skip: 0, back: 0, live: false, had: false, modeled: 0, reuse: true, check: false, reused: 0 } } + /// The same cache with the turn's own program read as A_t (`reuse`, the default: `Live::event` runs the turn first and hands + /// the deviation its first program's meta and exact marginals) or built and solved again by the deviation; `check`: when + /// handed, A_t is built and solved again as well and the two compared (a mismatch panics: a test's instrument) + pub fn with_reuse(mut self, reuse: bool, check: bool) -> ExpectCache { self.reuse = reuse; self.check = check; self } + /// Whether `Live::event` hands the deviation the turn's own program (the cache on, reuse on) + pub fn reuses(&self) -> bool { self.on && self.reuse } + /// The last event's A_t read from the turn (1) or not (0) + pub fn reused(&self) -> u32 { self.reused } /// Whether the cache is used pub fn is_on(&self) -> bool { self.on } /// The last event's programs answered from the cache and solved @@ -4057,7 +4095,7 @@ impl ExpectCache { self.rows.capacity() * size_of::<([u8; 32], Marg)>() + self.used.capacity() + self.rows.iter().map(|(_, m)| m.capacity() * size_of::<(String, Vec<(String, f64)>)>() + m.iter().map(|(id, ls)| id.capacity() + ls.capacity() * size_of::<(String, f64)>() + ls.iter().map(|(l, _)| l.capacity()).sum::()).sum::()).sum::() } - fn begin(&mut self) { self.hits = 0; self.solves = 0; self.modeled = 0; self.used.clear(); self.used.resize(self.rows.len(), false); + fn begin(&mut self) { self.hits = 0; self.solves = 0; self.modeled = 0; self.reused = 0; self.used.clear(); self.used.resize(self.rows.len(), false); self.live = self.on && self.skip == 0; self.had = !self.rows.is_empty(); if self.skip > 0 { self.skip -= 1; } } fn end(&mut self) { // an event that had rows to compare and found none backs off; one that started empty only fills (the next one compares) @@ -4068,6 +4106,11 @@ impl ExpectCache { if !self.live { return None; } let i = self.rows.iter().position(|r| r.0 == *key)?; self.used[i] = true; self.hits += 1; Some(self.rows[i].1.clone()) } + /// A_t's marginals from the turn, kept under `key` while the cache is live (not a solve of the deviation's) + fn put(&mut self, key: [u8; 32], m: &Marg) { + if !self.live { return; } + match self.rows.iter().position(|r| r.0 == key) { Some(i) => self.used[i] = true, None => { self.rows.push((key, m.clone())); self.used.push(true); } } + } /// A program solved here, not by `marg`: counted, and kept under `key` while the cache is live fn keep(&mut self, key: Option<[u8; 32]>, m: &Option) { self.solves += 1; @@ -4120,11 +4163,14 @@ fn kl_marg(a: &Marg, b: &Marg) -> Option<(f64, Vec)> { pub fn deviation(p: &Persona, st: &State, raw: &Json, mem: &ExpectMem, eng: Engine) -> Option { deviation_cached(p, st, raw, mem, eng, &mut ExpectCache::new(false)) } /// `deviation` with the members' marginals from `cache` (`ExpectCache`): the same value, fewer solves #[inline(never)] -pub fn deviation_cached(p: &Persona, st: &State, raw: &Json, mem: &ExpectMem, eng: Engine, cache: &mut ExpectCache) -> Option { - cache.begin(); let d = deviation_in(p, st, raw, mem, eng, cache); cache.end(); crate::trace::expect_cache(cache.hits, cache.solves); d +pub fn deviation_cached(p: &Persona, st: &State, raw: &Json, mem: &ExpectMem, eng: Engine, cache: &mut ExpectCache) -> Option { deviation_at(p, st, raw, mem, eng, cache, None) } +/// `deviation_cached` after the turn: `at` is what `turn_at` handed of the event's own program (None: A_t built and solved here) +#[inline(never)] +pub fn deviation_at(p: &Persona, st: &State, raw: &Json, mem: &ExpectMem, eng: Engine, cache: &mut ExpectCache, at: Option>>) -> Option { + cache.begin(); let d = deviation_in(p, st, raw, mem, eng, cache, at); cache.end(); crate::trace::expect_cache(cache.hits, cache.solves); d } #[inline(always)] -fn deviation_in(p: &Persona, st: &State, raw: &Json, mem: &ExpectMem, eng: Engine, cache: &mut ExpectCache) -> Option { +fn deviation_in(p: &Persona, st: &State, raw: &Json, mem: &ExpectMem, eng: Engine, cache: &mut ExpectCache, at: Option>>) -> Option { let ex = p.expect.as_ref()?; let kv = raw.as_obj()?; let e = get(kv, "elapsed_hours").cloned().unwrap_or(Json::Num(0.0)); let key = combo_key(p, kv); let ws = expect_weights(ex, mem); let member = ws.iter().any(|(k, _)| *k == key); @@ -4132,7 +4178,7 @@ fn deviation_in(p: &Persona, st: &State, raw: &Json, mem: &ExpectMem, eng: Engin // with this event's hours), in the mixture's order let mut ins = vec![with_elapsed(&Json::Obj(vec![]), &e)]; for (k, _) in &ws { if *k == key || k == "{}" { continue; } ins.push(with_elapsed(&mem.combos.iter().find(|x| x.0 == *k).map(|x| x.2.clone()).unwrap_or(Json::Obj(vec![])), &e)); } - let ms = members_marg(p, st, raw, &ins, eng, cache)?; + let ms = members_marg(p, st, raw, &ins, eng, cache, at)?; mixture(mem, &ws, &key, member, ms) } /// The deviation from the programs' marginals (A_t, E_t, then the other members in the mixture's order), out of the frame the @@ -4179,24 +4225,31 @@ struct Skel { parts: crate::run::Parts, idx: Vec> } /// `comment` and the vars' `h` (its skeleton), then the program's field: equal keys, equal texts. Inlined in the deviation's frame /// (each member's build runs right under it, as before) #[inline(always)] -fn members_marg(p: &Persona, st: &State, raw: &Json, ins: &[Json], eng: Engine, cache: &mut ExpectCache) -> Option>> { +fn members_marg(p: &Persona, st: &State, raw: &Json, ins: &[Json], eng: Engine, cache: &mut ExpectCache, at: Option>>) -> Option>> { let mut out = Vec::with_capacity(ins.len() + 1); if !cache.on { out.push(text_marg(p, st, raw, eng, cache)); if out[0].is_none() { return None; } for (i, x) in ins.iter().enumerate() { let m = text_marg(p, st, x, eng, cache); if i == 0 && m.is_none() { return None; } out.push(m); } return Some(out); } let t = crate::trace::Stopwatch::start(); - let Ok((prog, meta)) = program_meta(p, st, raw, false) else { crate::trace::expect_split(t.us(), 0); return None }; + // A_t: handed by the turn (its first program: the text written again from its meta, its answer's marginals), or built here + let (prog, meta, own) = match at { + Some(None) => { cache.reused = 1; if cache.check { assert!(program_json(p, st, raw).ok().and_then(|x| exact_marg(p, st, &x, eng)).is_none(), "A_t: not exact either way"); } return None } + Some(Some(a)) => { let At { marg, meta } = *a; cache.reused = 1; (member_text(p, &meta), meta, Some(marg)) } + None => { let Ok((prog, meta)) = program_meta(p, st, raw, false) else { crate::trace::expect_split(t.us(), 0); return None }; (prog, meta, None) } }; let (b, t) = (t.us(), crate::trace::Stopwatch::start()); let skel = cache.live.then(|| skel_key(&prog)); let mut sk: Option>> = None; // read on the first program the cache does not answer; Some(None): not usable - let a = model_marg(p, st, &prog, &meta, skel.as_ref(), &mut sk, true, eng, cache).flatten(); crate::trace::expect_split(b, t.us()); + let a = match own { + Some(m) => { if cache.check { at_check(p, st, raw, &prog, &m, eng); } if let Some(s) = &skel { cache.put(field_key(s, &meta.field), &m); } Some(m) } + None => model_marg(p, st, &prog, &meta, skel.as_ref(), &mut sk, &meta, true, eng, cache).flatten() }; + crate::trace::expect_split(b, t.us()); a.as_ref()?; out.push(a); for (i, x) in ins.iter().enumerate() { let t = crate::trace::Stopwatch::start(); let m = match program_meta(p, st, x, true) { Err(_) => { crate::trace::expect_split(t.us(), 0); None } Ok((_, mm)) if mm.rules == meta.rules => { let (b, t) = (t.us(), crate::trace::Stopwatch::start()); - let r = model_marg(p, st, &prog, &mm, skel.as_ref(), &mut sk, false, eng, cache); + let r = model_marg(p, st, &prog, &mm, skel.as_ref(), &mut sk, &meta, false, eng, cache); // another tier (or no usable skeleton): its text from its meta, run by the engine let r = match r { Some(r) => r, None => { let prog = member_text(p, &mm); cache.marg(p, st, &prog, eng) } }; crate::trace::expect_split(b, t.us()); r } // other rules in force: its text from its meta (no second build), run by the engine @@ -4205,14 +4258,24 @@ fn members_marg(p: &Persona, st: &State, raw: &Json, ins: &[Json], eng: Engine, out.push(m); } Some(out) } -/// One program of the event as A_t's model with its own `h` (from its compile meta `mm`; `own`: A_t itself, its parsed `h`): +/// The test instrument (`ExpectCache::with_reuse(_, true)`): the A_t the turn handed is the program the deviation builds for the +/// event, text for text, and its marginals are that program's run by the engine, bit for bit +#[inline(never)] +fn at_check(p: &Persona, st: &State, raw: &Json, prog: &Json, m: &Marg, eng: Engine) { + let own = program_json(p, st, raw).expect("A_t builds"); + assert_eq!(text(&own), text(prog), "A_t: the turn's first program is the deviation's"); + let r = exact_marg(p, st, &own, eng).expect("A_t exact"); + assert!(r.len() == m.len() && r.iter().zip(m).all(|(a, b)| a.0 == b.0 && a.1.len() == b.1.len() && a.1.iter().zip(&b.1).all(|(x, y)| x.0 == y.0 && x.1.to_bits() == y.1.to_bits())), "A_t: the turn's marginals are the engine's"); +} +/// One program of the event as A_t's model with its own `h` (from its compile meta `mm`; A_t's is `am`, its skeleton read with +/// it; `own`: A_t itself, its parsed `h`): /// Some(marginals or None) as the engine would answer; None when the enumeration would not answer it (the caller runs it) #[inline(never)] #[allow(clippy::too_many_arguments)] -fn model_marg(p: &Persona, st: &State, prog: &Json, mm: &Meta, skel: Option<&[u8; 32]>, sk: &mut Option>>, own: bool, eng: Engine, cache: &mut ExpectCache) -> Option> { +fn model_marg(p: &Persona, st: &State, prog: &Json, mm: &Meta, skel: Option<&[u8; 32]>, sk: &mut Option>>, am: &Meta, own: bool, eng: Engine, cache: &mut ExpectCache) -> Option> { let key = skel.map(|s| field_key(s, &mm.field)); if let Some(k) = &key { if let Some(m) = cache.find(k) { return Some(Some(m)); } } - if sk.is_none() { *sk = Some(skel_parts(p, prog, mm)); } + if sk.is_none() { *sk = Some(skel_parts(p, prog, am)); } let Some(Some(s)) = sk.as_ref() else { return if own { Some(cache.marg(p, st, prog, eng)) } else { None } }; let h = if own { s.parts.h().to_vec() } else { field_h(s, &mm.field) }; let Ok(mut m) = s.parts.model_with(h) else { cache.keep(key, &None); return Some(None) }; diff --git a/probbit-persona/src/strand.rs b/probbit-persona/src/strand.rs index efd484b..cd6176c 100644 --- a/probbit-persona/src/strand.rs +++ b/probbit-persona/src/strand.rs @@ -147,13 +147,15 @@ impl Live { Clock::Fixed => { let e = match given { None => 0.0, Some(Json::Num(x)) if x.is_finite() && *x >= 0.0 => *x, _ => return Err(perr("event.elapsed_hours", "a number >= 0")) }; (q6(e), self.last) } }; kv.push(("elapsed_hours".into(), Json::Num(elapsed))); let inputs = Json::Obj(kv); + // the turn first: with the cache on it hands the deviation the event's own program (A_t) and its exact answer + let want = self.expect.is_some() && self.expect_cache.reuses(); + let (mut stance, ns, at) = persona::turn_at(&self.p, &self.st, &inputs, eng, want)?; + let sd = persona::stance_sha(&stance); enter(Phase::Expect); // RFC 0009: the deviation and the trajectory are read from the state before the event (effects {}: the turn is as without them) let cache = &mut self.expect_cache; - let dev = self.expect.as_ref().and_then(|m| persona::deviation_cached(&self.p, &self.st, &inputs, m, eng, cache)); + let dev = self.expect.as_ref().and_then(|m| persona::deviation_at(&self.p, &self.st, &inputs, m, eng, cache, at)); let traj = persona::trajectory(&self.p, &self.st, &inputs); - let (mut stance, ns) = persona::turn_boxed(&self.p, &self.st, &inputs, false, eng, false)?; - let sd = persona::stance_sha(&stance); if self.expect.is_some() { if let Json::Obj(v) = &mut stance { v.push(("deviation".into(), dev.as_ref().map_or(Json::Null, |d| d.to_json(self.dev_full)))); } } // §2.18: the glyph reads the finished document, `deviation` included (the surprise modifier) persona::set_glyph(&self.p, &mut stance); From 3162947845c8470a80e3abe51f19a5eba35a4594 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 16:28:12 -0700 Subject: [PATCH 27/61] probbit-persona: a narrower prep copy for the expectation's members: the prep step no longer copies the senses and drive states it replaces (every turn and member: the same floats), and a member's copy (prep_boxed_n, narrow) leaves out what neither the steps nor the compiler read (the persona's name, the agenda, the resting stance, the learned-from counts, the digest) --- probbit-persona/src/persona.rs | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/probbit-persona/src/persona.rs b/probbit-persona/src/persona.rs index 1012532..56a859a 100644 --- a/probbit-persona/src/persona.rs +++ b/probbit-persona/src/persona.rs @@ -1433,10 +1433,13 @@ fn host_inputs(d: &Drives, ds: &DState, c: &DriveCtx, raw: &[(String, Json)]) -> /// against the normals, `src` the event's source, `check` the provenance check), then with a drives block the stepped drives and /// the host inputs plus the drive aggregates; then with a pace block (RFC 0002 Design C) the pace step and its two inputs; else the /// state and inputs as given -fn prep(p: &Persona, st: &State, raw: &[(String, Json)], src: Option<&Json>, check: bool) -> R<(State, Vec<(String, Json)>)> { +fn prep(p: &Persona, st: &State, raw: &[(String, Json)], src: Option<&Json>, check: bool) -> R<(State, Vec<(String, Json)>)> { prep_n(p, st, raw, src, check, false) } +/// `prep`; `narrow`: an expectation member's (`State::prep_clone`) +#[inline(always)] +fn prep_n(p: &Persona, st: &State, raw: &[(String, Json)], src: Option<&Json>, check: bool, narrow: bool) -> R<(State, Vec<(String, Json)>)> { // RFC 0002 part 2: the engine sets load_level from L (the host never sends it) if p.load.is_some() { need(get(raw, LOAD_ID).is_none(), "inputs.load_level", "reserved for the load block (the engine sets it from the load)")?; } - let (mut s2, mut host) = prep_body(p, st, raw, src, check)?; + let (mut s2, mut host) = prep_body(p, st, raw, src, check, narrow)?; pressure_step(p, &mut s2, raw)?; load_step(p, st, &mut s2, raw, &mut host)?; Ok((s2, host)) @@ -1511,8 +1514,8 @@ pub fn load_level(p: &Persona, st: &State) -> Option<(f64, f64)> { p.load.as_ref /// The rest pressure of a state with a rest block (RFC 0002 part 2): (S, pending); none without one pub fn rest_pressure(st: &State) -> Option<(f64, bool)> { st.pressure } /// prep without part 2: the sense, needs, drive and pace steps -fn prep_body(p: &Persona, st: &State, raw: &[(String, Json)], src: Option<&Json>, check: bool) -> R<(State, Vec<(String, Json)>)> { - let mut s2 = st.clone(); let mut x = raw.to_vec(); +fn prep_body(p: &Persona, st: &State, raw: &[(String, Json)], src: Option<&Json>, check: bool, narrow: bool) -> R<(State, Vec<(String, Json)>)> { + let mut s2 = st.prep_clone((p.senses.is_some() && st.senses.is_some(), p.drives.is_some() && st.drives.is_some()), narrow); let mut x = raw.to_vec(); // RFC 0002 Design C: the engine sets pace_up and pace_down from R̄ (the host never sends them) if p.pace.is_some() { for k in PACE_IDS { need(get(&x, k).is_none(), &format!("inputs.{k}"), "reserved for the pace block (the engine sets it from the average reward)")?; } } if let (Some(sn), Some(ss)) = (&p.senses, &st.senses) { let (ns, host) = sense_step(sn, ss, &x, src, check)?; s2.senses = Some(ns); x = host; } @@ -2300,6 +2303,18 @@ impl State { fn disp_of(&self, k: &str) -> Option { self.disp.iter().find(|(x, _)| x == k).map(|(_, v)| *v) } /// The surprise gain per learned trait in `learning.traits` order (RFC 0002 Design B); empty without a surprise block (gain 1) fn gains(&self) -> Vec { self.gate.as_ref().map_or(vec![], |gs| gs.iter().map(|(_, g)| g.g).collect()) } + /// The prep step's copy of the state (`prep_body`): the senses and drive states it steps are not copied (`stepped`: the + /// senses' then the drives' are replaced before anything reads them); `narrow` (an expectation member: its state is read + /// by the steps and the compiler only) also leaves out what neither reads: the persona's name, the agenda, the resting + /// stance, the learned-from counts and the digest + fn prep_clone(&self, stepped: (bool, bool), narrow: bool) -> State { + let w = |b: bool| !(narrow && b); + State { seed: self.seed, turn: self.turn, persona: if w(true) { self.persona.clone() } else { Default::default() }, genes: self.genes.clone(), mood: self.mood.clone(), + history: self.history.clone(), prev: self.prev.clone(), agenda: self.agenda.as_ref().filter(|_| w(true)).cloned(), rest: self.rest.as_ref().filter(|_| w(true)).cloned(), + learned: self.learned.clone(), credit: self.credit.clone(), drives: if stepped.1 { None } else { self.drives.clone() }, senses: if stepped.0 { None } else { self.senses.clone() }, + learned_from: self.learned_from.as_ref().filter(|_| w(true)).cloned(), open: self.open.clone(), prev_stance: self.prev_stance.clone(), control_closed: self.control_closed.clone(), + elig: self.elig, digest: if w(true) { self.digest.clone() } else { String::new() }, gate: self.gate.clone(), rbar: self.rbar, disp: self.disp.clone(), pressure: self.pressure, load_l: self.load_l } + } 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 { @@ -2940,6 +2955,9 @@ fn turn_prep(p: &Persona, st: &State, raw: &Json, check: bool) -> R { fn rest_boxed(p: &Persona, st: &State) -> Box { Box::new(rest_apply(p, st)) } #[inline(never)] fn prep_boxed(p: &Persona, st: &State, raw: &[(String, Json)], src: Option<&Json>, check: bool) -> R<(Box, Vec<(String, Json)>)> { let (s, x) = prep(p, st, raw, src, check)?; Ok((Box::new(s), x)) } +/// `prep_boxed` for an expectation member (`narrow`) +#[inline(never)] +fn prep_boxed_n(p: &Persona, st: &State, raw: &[(String, Json)], src: Option<&Json>, check: bool, narrow: bool) -> R<(Box, Vec<(String, Json)>)> { let (s, x) = prep_n(p, st, raw, src, check, narrow)?; Ok((Box::new(s), x)) } #[inline(never)] fn credit_boxed(p: &Persona, st: &State, host: &[(String, Json)], k: &Pay, src: Option<&Json>, check: bool) -> R<(Box, Settled)> { let (s, d) = credit_step(p, st, host, k, src, check)?; Ok((Box::new(s), d)) } #[inline(never)] @@ -3151,7 +3169,7 @@ fn program_meta(p: &Persona, st: &State, raw: &Json, meta_only: bool) -> R<(Json let raw: Vec<(String, Json)> = match raw { Json::Null => vec![], Json::Obj(v) => v.clone(), _ => return Err(perr("inputs", "must be a JSON object")) }; let (src, raw) = if p.senses.is_some() || p.credit().is_some() || p.has_needs() { take_src(p, raw, true)? } else { (None, raw) }; let (pay, raw) = take_pay(p, raw)?; - let (st, raw) = prep_boxed(p, st, &raw, src.as_ref(), true)?; + let (st, raw) = prep_boxed_n(p, st, &raw, src.as_ref(), true, meta_only)?; let st = match &pay { Some(k) => credit_boxed(p, &st, &raw, k, src.as_ref(), true)?.0, None => st }; let st = gate_boxed(p, st, &raw)?; let (j, m) = compile(p, &st, &raw, &Opts { meta_only, ..Default::default() })?; Ok((j, Box::new(m))) From 9a16389f13c858b7f8f5eaccb7f51f98d8325ebc Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 16:28:12 -0700 Subject: [PATCH 28/61] probbit-persona: no why text for the expectation's members: with Opts::meta_only the compiler's contributions carry no source name (no per-input format!, no name strings); the field, the rules and every number are the same --- probbit-persona/src/persona.rs | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/probbit-persona/src/persona.rs b/probbit-persona/src/persona.rs index 56a859a..8f88cc1 100644 --- a/probbit-persona/src/persona.rs +++ b/probbit-persona/src/persona.rs @@ -2549,6 +2549,8 @@ fn compile(p: &Persona, st: &State, raw: &[(String, Json)], o: &Opts) -> R<(Json 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]; + // a contribution's source names the turn's `why` (and `explain`); a member's meta (`meta_only`) is never read for it: unnamed + let nm = |s: &str| if o.meta_only { String::new() } else { s.to_string() }; let mut mood_ev: Vec>> = p.vars.iter().map(|v| v.mood.then(|| vec![0.0; v.levels.len()])).collect(); // the learned deltas after this turn's feedback (the reward flag +1, the correction flag -1) on the previous stance's credit // with a drives block (§2.9): the turn's drive context, read from the (stepped) drive state; its surprise joins the learning sign @@ -2558,11 +2560,11 @@ fn compile(p: &Persona, st: &State, raw: &[(String, Json)], o: &Opts) -> R<(Json let sign = l.from.iter().enumerate().fold(0.0, |s, (i, id)| if matches!(get(&vals, id), Some(Json::Bool(true))) { s + if i == 0 { 1.0 } else { -1.0 } } else { s }); let sign = match &dctx { Some(c) => (sign + c.learn_sign).clamp(-1.0, 1.0), None => sign }; learn_sign = sign; learn(l, &st.credit, sign, st.elig, &st.learned, &st.gains(), st.disp_of("optimism")) } }; - 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())); } - if let Some((_, d)) = new_learned.iter().find(|(t, d)| *t == v.id && d.iter().any(|x| *x != 0.0)) { contrib[i].push(("learned".into(), d.clone())); } } - 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())); } } + for (i, v) in p.vars.iter().enumerate() { contrib[i].push((nm("prior"), v.base.clone())); + if let Some(g) = st.shift(&v.id) { contrib[i].push((nm("genes"), centered(v.levels.len()).iter().map(|c| g * c).collect())); } + if let Some((_, d)) = new_learned.iter().find(|(t, d)| *t == v.id && d.iter().any(|x| *x != 0.0)) { contrib[i].push((nm("learned"), d.clone())); } } + for (j, s) in p.steps.iter().enumerate() { contrib[nv + j].push((nm("prior"), vec![0.0; ns])); + if let Some((_, pf)) = p.prefer.iter().find(|(t, _)| *t == format!("step.{s}")) { contrib[nv + j].push((nm("prefer"), 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)) } }; @@ -2572,17 +2574,17 @@ fn compile(p: &Persona, st: &State, raw: &[(String, Json)], o: &Opts) -> R<(Json 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 (t, vec) in eff { push(t, if o.meta_only { String::new() } else { 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); } } + for (t, vec) in &h.effects { push(t, if o.meta_only { String::new() } else { 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())); } + if m.iter().any(|&x| x != 0.0) { contrib[i].push((nm("mood_accumulator"), 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(); @@ -2593,7 +2595,7 @@ fn compile(p: &Persona, st: &State, raw: &[(String, Json)], o: &Opts) -> R<(Json let sem = values_of(p); // `pursue`'s drive terms after its prior, learned, input and history contributions: genes, wanting, afterglow, deadline (§3 step 2b) let pix = dctx.as_ref().and_then(|_| p.vars.iter().position(|v| v.id == "pursue")); - if let (Some(c), Some(i)) = (&dctx, pix) { for (src, vec) in &c.terms { if vec.iter().any(|x| *x != 0.0) { contrib[i].push((src.to_string(), vec.clone())); } } } + if let (Some(c), Some(i)) = (&dctx, pix) { for (src, vec) in &c.terms { if vec.iter().any(|x| *x != 0.0) { contrib[i].push((nm(*src), vec.clone())); } } } let total = |c: &[(String, Vec)], n: usize| -> Vec { let mut tot = vec![0.0; n]; for (_, vec) in c { for (a, b) in tot.iter_mut().zip(vec) { *a += b; } } tot.into_iter().map(r6).collect() }; let mut field: Vec> = (0..nv + ns).map(|i| total(&contrib[i], if i < nv { p.vars[i].levels.len() } else { ns })).collect(); // the floor lift (§2.9): on the rounded field, over the levels the habits in force allow; then the field is summed again @@ -2601,7 +2603,7 @@ fn compile(p: &Persona, st: &State, raw: &[(String, Json)], o: &Opts) -> R<(Json if let (Some(d), Some(i)) = (&p.drives, pix) { if d.goals.iter().any(|g| g.floor > 0.0) { let ti = RULE_KEYS.iter().position(|k| *k == "tables").unwrap(); let l = floor_lifts(p, d, &field[i], &pursue_allowed(&p.vars[i].levels, &rules[ti])); - if l.iter().any(|x| *x != 0.0) { contrib[i].push(("floor".into(), l.clone())); field[i] = total(&contrib[i], l.len()); } + if l.iter().any(|x| *x != 0.0) { contrib[i].push((nm("floor"), l.clone())); field[i] = total(&contrib[i], l.len()); } lift = d.goals.iter().zip(&l).filter(|(_, x)| **x != 0.0).map(|(g, x)| (g.id.clone(), *x)).collect(); } } let use_twin = o.twin.unwrap_or(p.eng.twin); let ids: Vec = active.iter().map(|h| h.id.clone()).collect(); From b1c0412d4e8e56acfb051d3470a4438c9f5cee43 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 16:28:40 -0700 Subject: [PATCH 29/61] probbit-persona: the heap inside expect: the members share one model (run::Members: A_t's parts moved into it, the compile pass's folds kept apart and replayed per member in the pass's order, checked bit for bit on A_t's own h), so A_t's parts and a member's model are no longer held together; A_t's text is dropped once it is read into its parts; the trajectory runs before the turn again (nothing of the turn held while it solves), the deviation after it --- probbit-persona/src/persona.rs | 58 +++++++++++++++++++++------------- probbit-persona/src/run.rs | 39 +++++++++++++++++++++++ probbit-persona/src/strand.rs | 8 +++-- 3 files changed, 80 insertions(+), 25 deletions(-) diff --git a/probbit-persona/src/persona.rs b/probbit-persona/src/persona.rs index 8f88cc1..b22e607 100644 --- a/probbit-persona/src/persona.rs +++ b/probbit-persona/src/persona.rs @@ -4233,9 +4233,10 @@ fn text_marg(p: &Persona, st: &State, ins: &Json, eng: Engine, cache: &mut Expec let t = crate::trace::Stopwatch::start(); let prog = program_json(p, st, ins).ok(); let b = t.us(); let m = prog.and_then(|prog| cache.marg(p, st, &prog, eng)); crate::trace::expect_split(b, t.us() - b); m } -/// The event's own program's model parts, read once from its text (`run::from_json_parts`), with per variable of the persona -/// (traits and moods, then the agenda steps) the value index of each of its levels in the program -struct Skel { parts: crate::run::Parts, idx: Vec> } +/// The event's own program read once from its text (`run::from_json_parts`) into the members' one model (`run::Members`: its +/// parts moved in, no second copy), its variables, values and own `h`, with per variable of the persona (traits and moods, then +/// the agenda steps) the value index of each of its levels in the program +struct Skel { vars: Vec, values: Vec, h0: Vec, mm: crate::run::Members, idx: Vec> } /// The expectation's programs -> per program its exact marginals (None: it does not compile or is not solved exactly); None when /// A_t or E_t has none (no deviation). With the cache off (`persona::deviation`, the reference) every program's text is built /// and run by the engine. With it on, A_t's text is built and read once; every other program is compiled without its text @@ -4256,20 +4257,23 @@ fn members_marg(p: &Persona, st: &State, raw: &Json, ins: &[Json], eng: Engine, Some(None) => { cache.reused = 1; if cache.check { assert!(program_json(p, st, raw).ok().and_then(|x| exact_marg(p, st, &x, eng)).is_none(), "A_t: not exact either way"); } return None } Some(Some(a)) => { let At { marg, meta } = *a; cache.reused = 1; (member_text(p, &meta), meta, Some(marg)) } None => { let Ok((prog, meta)) = program_meta(p, st, raw, false) else { crate::trace::expect_split(t.us(), 0); return None }; (prog, meta, None) } }; + let mut prog = Some(prog); let (b, t) = (t.us(), crate::trace::Stopwatch::start()); - let skel = cache.live.then(|| skel_key(&prog)); + let skel = cache.live.then(|| skel_key(prog.as_ref().unwrap())); let mut sk: Option>> = None; // read on the first program the cache does not answer; Some(None): not usable let a = match own { - Some(m) => { if cache.check { at_check(p, st, raw, &prog, &m, eng); } if let Some(s) = &skel { cache.put(field_key(s, &meta.field), &m); } Some(m) } - None => model_marg(p, st, &prog, &meta, skel.as_ref(), &mut sk, &meta, true, eng, cache).flatten() }; + Some(m) => { if cache.check { at_check(p, st, raw, prog.as_ref().unwrap(), &m, eng); } if let Some(s) = &skel { cache.put(field_key(s, &meta.field), &m); } Some(m) } + None => model_marg(p, st, &mut prog, &meta, skel.as_ref(), &mut sk, &meta, true, eng, cache).flatten() }; crate::trace::expect_split(b, t.us()); a.as_ref()?; out.push(a); for (i, x) in ins.iter().enumerate() { + // A_t's text is read no more once its skeleton is (the members' model holds what they need) + if sk.is_some() { prog = None; } let t = crate::trace::Stopwatch::start(); let m = match program_meta(p, st, x, true) { Err(_) => { crate::trace::expect_split(t.us(), 0); None } Ok((_, mm)) if mm.rules == meta.rules => { let (b, t) = (t.us(), crate::trace::Stopwatch::start()); - let r = model_marg(p, st, &prog, &mm, skel.as_ref(), &mut sk, &meta, false, eng, cache); + let r = model_marg(p, st, &mut prog, &mm, skel.as_ref(), &mut sk, &meta, false, eng, cache); // another tier (or no usable skeleton): its text from its meta, run by the engine let r = match r { Some(r) => r, None => { let prog = member_text(p, &mm); cache.marg(p, st, &prog, eng) } }; crate::trace::expect_split(b, t.us()); r } // other rules in force: its text from its meta (no second build), run by the engine @@ -4292,37 +4296,47 @@ fn at_check(p: &Persona, st: &State, raw: &Json, prog: &Json, m: &Marg, eng: Eng /// Some(marginals or None) as the engine would answer; None when the enumeration would not answer it (the caller runs it) #[inline(never)] #[allow(clippy::too_many_arguments)] -fn model_marg(p: &Persona, st: &State, prog: &Json, mm: &Meta, skel: Option<&[u8; 32]>, sk: &mut Option>>, am: &Meta, own: bool, eng: Engine, cache: &mut ExpectCache) -> Option> { +fn model_marg(p: &Persona, st: &State, prog: &mut Option, mm: &Meta, skel: Option<&[u8; 32]>, sk: &mut Option>>, am: &Meta, own: bool, eng: Engine, cache: &mut ExpectCache) -> Option> { let key = skel.map(|s| field_key(s, &mm.field)); if let Some(k) = &key { if let Some(m) = cache.find(k) { return Some(Some(m)); } } - if sk.is_none() { *sk = Some(skel_parts(p, prog, am)); } - let Some(Some(s)) = sk.as_ref() else { return if own { Some(cache.marg(p, st, prog, eng)) } else { None } }; - let h = if own { s.parts.h().to_vec() } else { field_h(s, &mm.field) }; - let Ok(mut m) = s.parts.model_with(h) else { cache.keep(key, &None); return Some(None) }; - let Some(r) = crate::run::enumerated(&mut m, p.eng.exact_limit, p.eng.sweeps) else { return if own { Some(cache.marg(p, st, prog, eng)) } else { None } }; - let r = r.map(|mg| { let k = m.k; s.parts.vars.iter().enumerate().filter(|(_, id)| !id.starts_with("free.")).map(|(i, id)| (id.clone(), { - let mut l: Vec<(String, f64)> = (0..k).filter(|&q| m.allowed[i * k + q]).map(|q| (s.parts.values[q].clone(), mg[i * k + q])).collect(); l.sort_by(|a, b| a.0.cmp(&b.0)); l })).collect() }); + // A_t's text is read into its parts and dropped before the members' model is built from them (A_t itself, unhanded, keeps + // its text: its own fallback runs it) + if sk.is_none() { *sk = Some(if own { prog.as_ref().and_then(|x| skel_parts(p, x, am)) } else { prog.take().and_then(|x| skel_parts_owned(p, x, am)) }); } + let own_run = |prog: &Option, cache: &mut ExpectCache| prog.as_ref().and_then(|x| cache.marg(p, st, x, eng)); + let Some(Some(s)) = sk.as_mut() else { return if own { Some(own_run(prog, cache)) } else { None } }; + let h = if own { s.h0.clone() } else { field_h(s, &mm.field) }; + if s.mm.set_h(h).is_err() { cache.keep(key, &None); return Some(None) } + let Some(r) = crate::run::enumerated(&mut s.mm.m, p.eng.exact_limit, p.eng.sweeps) else { return if own { Some(own_run(prog, cache)) } else { None } }; + let r = r.map(|mg| { let m = &s.mm.m; let k = m.k; s.vars.iter().enumerate().filter(|(_, id)| !id.starts_with("free.")).map(|(i, id)| (id.clone(), { + let mut l: Vec<(String, f64)> = (0..k).filter(|&q| m.allowed[i * k + q]).map(|q| (s.values[q].clone(), mg[i * k + q])).collect(); l.sort_by(|a, b| a.0.cmp(&b.0)); l })).collect() }); cache.modeled += 1; cache.keep(key, &r); Some(r) } /// A_t's text read into its parts, and the value index of every level -> None when the parts do not have the shape the compiler /// writes (then every program is run the reference way); checked: A_t's own field mapped to `h` is the `h` its text carries #[inline(never)] -fn skel_parts(p: &Persona, prog: &Json, mm: &Meta) -> Option> { - let parts = crate::run::from_json_parts(prog).ok()?; +fn skel_parts(p: &Persona, prog: &Json, mm: &Meta) -> Option> { skel_of(p, crate::run::from_json_parts(prog).ok()?, mm) } +/// `skel_parts`, the text dropped once its parts are read +#[inline(never)] +fn skel_parts_owned(p: &Persona, prog: Json, mm: &Meta) -> Option> { let parts = crate::run::from_json_parts(&prog).ok(); drop(prog); skel_of(p, parts?, mm) } +/// The skeleton from A_t's parts (`skel_parts`) +fn skel_of(p: &Persona, parts: crate::run::Parts, mm: &Meta) -> Option> { let (nv, ns) = (p.vars.len(), p.steps.len()); if parts.vars.len() != (nv + ns) * if mm.twin { 2 } else { 1 } { return None; } let q = |name: &str| parts.values.iter().position(|x| x == name); let mut idx = vec![]; for v in &p.vars { idx.push(v.levels.iter().enumerate().map(|(j, l)| q(&if p.eng.positional { format!("l{j}") } else { l.clone() })).collect::>>()?); } for _ in &p.steps { idx.push(p.slots.iter().map(|l| q(l)).collect::>>()?); } - let s = Box::new(Skel { parts, idx }); - (field_h(&s, &mm.field).iter().zip(s.parts.h()).all(|(a, b)| a.to_bits() == b.to_bits())).then_some(s) + let (n, k) = (parts.vars.len(), parts.values.len()); + if !field_hn(n, k, &idx, &mm.field).iter().zip(parts.h()).all(|(a, b)| a.to_bits() == b.to_bits()) { return None; } + let (vars, values, h0, mm) = parts.into_members()?; + Some(Box::new(Skel { vars, values, h0, mm, idx })) } /// A field (per persona variable, per level) as the program's `h` (n x k): the value index each level gets, 0 where the text /// omits a weight, the twins' rows copied -fn field_h(s: &Skel, field: &[Vec]) -> Vec { - let (n, k, m) = (s.parts.vars.len(), s.parts.values.len(), s.idx.len()); let mut h = vec![0.0; n * k]; - for r in 0..n { let i = r % m; for (&q, &w) in s.idx[i].iter().zip(&field[i]) { if w != 0.0 { h[r * k + q] = w; } } } +fn field_h(s: &Skel, field: &[Vec]) -> Vec { field_hn(s.vars.len(), s.values.len(), &s.idx, field) } +fn field_hn(n: usize, k: usize, idx: &[Vec], field: &[Vec]) -> Vec { + let m = idx.len(); let mut h = vec![0.0; n * k]; + for r in 0..n { let i = r % m; for (&q, &w) in idx[i].iter().zip(&field[i]) { if w != 0.0 { h[r * k + q] = w; } } } h } /// A_t's text without `comment` and without the vars' `h`, hashed as `prog_key` hashes a program diff --git a/probbit-persona/src/run.rs b/probbit-persona/src/run.rs index fca7c67..9062891 100644 --- a/probbit-persona/src/run.rs +++ b/probbit-persona/src/run.rs @@ -49,6 +49,45 @@ impl Parts { Ok(m) } } +/// RFC 0009 (the expectation's members, T3-b): one model for every program of an event that differs from A_t only in `h`. The +/// compile pass's folds (each separable table, in the pass's order: per value the row term into its first variable, then the +/// column term into its second) are kept apart, so a member's model is this one with the member's `h`, the folds replayed in +/// the same order: the same floats `compile_parts` makes, with no copy of the pairs and no second model held +pub struct Members { pub m: Model, folds: Vec<(usize, usize, Vec, Vec)> } +impl Members { + /// The model with `h` (n x k) in place of its own, as `Parts::model_with` builds it: each weight checked as the text's, the + /// folds added in the pass's order, then `Model::new`'s finite check + pub fn set_h(&mut self, mut h: Vec) -> Result<(), InErr> { + let bad = || crate::json::value("vars", "a weight beyond the finite weight limit"); + if h.len() != self.m.h.len() || h.iter().any(|x| !x.is_finite() || x.abs() > crate::json::MAX_WEIGHT) { return Err(bad()); } + let k = self.m.k; + for (i, j, row, col) in &self.folds { for a in 0..k { h[i * k + a] += row[a]; h[j * k + a] += col[a]; } } + if h.iter().any(|x| !x.is_finite()) { return Err(crate::json::value("", "h must contain only finite weights")); } + self.m.h = h; Ok(()) + } +} +impl Parts { + /// The members' model (`Members`): this program's model built from its parts (moved, as `into_prog`: no copy of the pairs), + /// its folds apart, with its variables, values and own `h` -> None when the folds read here are not the pass's (checked: + /// replayed on the program's own `h` they give the model's `h` bit for bit, and the pairs add up) + pub fn into_members(self) -> Option<(Vec, Vec, Vec, Members)> { + let k = self.values.len(); let mut folds = vec![]; let mut dropped = 0; + for p in &self.pairs { match &p.c { + Coupling::Potts(w) if *w == 0.0 => dropped += 1, + Coupling::Table(t) if t.iter().all(|&x| x == 0.0) => dropped += 1, + Coupling::Table(t) if t.len() == k * k && (0..k).all(|a| (0..k).all(|b| t[a * k + b] == t[a * k] + (t[b] - t[0]))) => + folds.push((p.i, p.j, (0..k).map(|a| t[a * k]).collect(), (0..k).map(|a| t[a] - t[0]).collect())), + _ => {} } } + let np = self.pairs.len(); + let Parts { vars, values, h, allowed, clamp, pairs, caps, start } = self; let h0 = h.clone(); let mut h = h; + let (pairs, caps, _) = compile_parts(k, &mut h, pairs, caps); + let mut m = Model::new(vars.len(), k, h, allowed, clamp, pairs, caps).ok()?; m.start = start; + if m.pairs.len() + folds.len() + dropped != np { return None; } + let want: Vec = m.h.iter().map(|x| x.to_bits()).collect(); let mut s = Members { m, folds }; + s.set_h(h0.clone()).ok()?; + (s.m.h.iter().map(|x| x.to_bits()).collect::>() == want).then_some((vars, values, h0, s)) + } +} /// `from_json` up to the compile pass (inlined: `from_json` keeps one frame) #[inline(always)] pub fn from_json_parts(j: &Json) -> Result { diff --git a/probbit-persona/src/strand.rs b/probbit-persona/src/strand.rs index cd6176c..3c16a3c 100644 --- a/probbit-persona/src/strand.rs +++ b/probbit-persona/src/strand.rs @@ -147,15 +147,17 @@ impl Live { Clock::Fixed => { let e = match given { None => 0.0, Some(Json::Num(x)) if x.is_finite() && *x >= 0.0 => *x, _ => return Err(perr("event.elapsed_hours", "a number >= 0")) }; (q6(e), self.last) } }; kv.push(("elapsed_hours".into(), Json::Num(elapsed))); let inputs = Json::Obj(kv); - // the turn first: with the cache on it hands the deviation the event's own program (A_t) and its exact answer + enter(Phase::Expect); + // RFC 0009: the deviation and the trajectory are read from the state before the event (effects {}: the turn is as without + // them). The trajectory before the turn (nothing of the turn held while it solves), the deviation after it: with the + // cache on the turn hands it the event's own program (A_t) and its exact answer + let traj = persona::trajectory(&self.p, &self.st, &inputs); let want = self.expect.is_some() && self.expect_cache.reuses(); let (mut stance, ns, at) = persona::turn_at(&self.p, &self.st, &inputs, eng, want)?; let sd = persona::stance_sha(&stance); enter(Phase::Expect); - // RFC 0009: the deviation and the trajectory are read from the state before the event (effects {}: the turn is as without them) let cache = &mut self.expect_cache; let dev = self.expect.as_ref().and_then(|m| persona::deviation_at(&self.p, &self.st, &inputs, m, eng, cache, at)); - let traj = persona::trajectory(&self.p, &self.st, &inputs); if self.expect.is_some() { if let Json::Obj(v) = &mut stance { v.push(("deviation".into(), dev.as_ref().map_or(Json::Null, |d| d.to_json(self.dev_full)))); } } // §2.18: the glyph reads the finished document, `deviation` included (the surprise modifier) persona::set_glyph(&self.p, &mut stance); From 978d60d0c2a25fdc8eb0a87a01fd6894b26a57a7 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 17:01:11 -0700 Subject: [PATCH 30/61] docs: the expectation's members, what the deviation reuses (docs/embedding.md: A_t from the turn and when, the member path's cost, the proxy's numbers, the heap with the new order and the switch that keeps the old one); CHANGELOG --- CHANGELOG.md | 9 +++++++++ docs/embedding.md | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15afa2d..ad31e0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project are documented here. The format follows ## Unreleased +- **The expectation's members, trimmed** (docs/embedding.md "The expectation's members: what the deviation reuses"): + `Live::event` runs the turn first and, with the cache on, the turn hands the deviation its first program's meta and exact + marginals (`persona::turn_at`, `persona::deviation_at`), so the event's own program is compiled and solved once (when no rest + is due, `src` is taken out alike and the op is `decide`; else as before). A member's prep copies only what it steps and + reads, its compile writes no source names, and the members share one model (`run::Members`, the compile pass's folds + replayed per member). `ExpectCache::with_reuse` keeps the old order or checks the reuse. Every stance, strand line and digest + is the same; proxy, the body persona's recorded day: `expect` 67.7% → 63.9% of the turn (-14% per event). The deviation + runs after the turn, so the event's heap peak (the body persona, proxy) is 97,583 B (89,990 before); `with_reuse(false, false)` + keeps the old order. - **Resume robustness** (docs/embedding.md "Resume on a body", docs/persona.md section 5.7 "Resume from a record"): `strand::Replay::open_boxed` checks the header as the chain check does (the persona built on the heap, the header written again in pieces) and keeps its `Live` boxed, so a replay from the header fits a small stack (proxy: 24,752 → 16,192 B); diff --git a/docs/embedding.md b/docs/embedding.md index 50aa5d2..a165be9 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -205,6 +205,39 @@ compiler's arithmetic, then the components tier; with M members per event this i every member. The body persona's turn on the proxy takes 13,824 B of stack (13,840 before) and its heap peak is unchanged (89,990 B, at the turn's solve; inside `expect` the peak rises from 68,476 to 87,214 B: A_t's parts and one member's model). +### The expectation's members: what the deviation reuses + +The event's own program A_t was compiled and solved twice per event: once by the turn, once as the deviation's own member. +Since the turn's first compile reads the same state and inputs as the deviation's A_t, `Live::event` now runs the turn first +and, with the cache on, the turn hands the deviation its first program's compile meta and that answer's exact marginals +(`persona::turn_at`, `persona::deviation_at`). The two programs are the same text when no rest line is due before the event, +`src` is taken out of the inputs alike (a `reward_from` without a senses, credit or needs block keeps it in the turn only) and +the persona's op is `decide`; the answer's marginals are the deviation's when its verdict is exact (an answer without an exact +verdict means no deviation, either way). Any other event: the deviation builds and solves A_t itself, as before. A_t's text is +written again from its meta (no prep, no compile) only to read the members' skeleton and key. `ExpectCache::with_reuse(false, +_)` keeps the old order (A_t built and solved by the deviation); `with_reuse(true, true)` builds and solves A_t a second time +as well and compares the two, text for text and bit for bit (a test's instrument). The phases' order in `turn_trace` changes: +the turn's phases come before `expect`. + +The member path's cost after that: prep copies only what it steps and reads (the senses and drive states it replaces are not +copied first; a member's copy leaves out the persona's name, the agenda, the resting stance, the learned-from counts and the +digest); the compiler's contributions carry no source name for a member (`Opts::meta_only`: the names exist for the turn's +`why`); and the members share one model (`run::Members`: A_t's parts moved into it, the compile pass's folds of separable +tables kept apart and replayed per member in the pass's order, checked bit for bit on A_t's own `h`), so no second copy of the +pairs is built per member; A_t's text is dropped once it is read. The trajectory runs before the turn, the deviation after +it. Every stance, strand line and digest is the same as before (and as the cache-off reference). + +On the proxy (median of 3–5 runs, cache on, `expect`'s share of the turn, before → after): the body persona's recorded day +67.7% → 63.9% (`expect` 1,042 → 894 µs per event, -14%; the rest of the turn about 500 µs; 5.63 member programs per event, +each about 79 µs to build and 69 µs to solve); its two recorded events 43.3% → 26.3%; the fixture persona, one event repeated, +35.8% → 32.9%; 500 events of many combinations 81.9% → 81.7%. Each step: A_t once 65.2%, the prep copy 64.7%, no source names +64.4%, one model 63.8%. Stack: the body persona's turn 13,584 B (13,824 before). Heap (the body persona's two recorded events, the +last one): the deviation now runs while the turn's stance document and next state are held (about 36.7 KB), so the event's +peak is 97,583 B, inside `expect` (89,990 before, at the turn's solve; inside `expect` 87,214); the deviation's own peak above +what it finds held fell from 86,209 to 60,857 B (one model instead of A_t's parts and a member's model; a member that keeps the +engine's path still builds its text and the engine's answer). A host short of heap keeps the old order with +`ExpectCache::with_reuse(false, false)`: the same bytes, A_t built and solved by the deviation. + Silicon numbers (the same calls on the target's engine thread) are measured by the firmware and will follow; the x86_64 proxy and an Xtensa build differ in frame layout, so read the proxy as the shape and the ratio, not the target's bytes. From 3d7b33d66f87181103b5d9ae56d39ac220cc2375 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 17:06:34 -0700 Subject: [PATCH 31/61] probbit-persona: the old order when the cache does not reuse the turn's own program (ExpectCache::with_reuse(false, _), the cache off): the deviation before the turn, nothing of the turn held while it runs (proxy, the body persona: the event's heap peak 89,990 B at the turn's solve, inside expect 69,332 B; with the reuse 97,583 B); docs: the heap with each order --- CHANGELOG.md | 2 +- docs/embedding.md | 3 ++- probbit-persona/src/strand.rs | 8 +++++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad31e0c..ce6b029 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ All notable changes to this project are documented here. The format follows replayed per member). `ExpectCache::with_reuse` keeps the old order or checks the reuse. Every stance, strand line and digest is the same; proxy, the body persona's recorded day: `expect` 67.7% → 63.9% of the turn (-14% per event). The deviation runs after the turn, so the event's heap peak (the body persona, proxy) is 97,583 B (89,990 before); `with_reuse(false, false)` - keeps the old order. + keeps the old order (the deviation before the turn: 89,990 B). - **Resume robustness** (docs/embedding.md "Resume on a body", docs/persona.md section 5.7 "Resume from a record"): `strand::Replay::open_boxed` checks the header as the chain check does (the persona built on the heap, the header written again in pieces) and keeps its `Live` boxed, so a replay from the header fits a small stack (proxy: 24,752 → 16,192 B); diff --git a/docs/embedding.md b/docs/embedding.md index a165be9..2791229 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -236,7 +236,8 @@ last one): the deviation now runs while the turn's stance document and next stat peak is 97,583 B, inside `expect` (89,990 before, at the turn's solve; inside `expect` 87,214); the deviation's own peak above what it finds held fell from 86,209 to 60,857 B (one model instead of A_t's parts and a member's model; a member that keeps the engine's path still builds its text and the engine's answer). A host short of heap keeps the old order with -`ExpectCache::with_reuse(false, false)`: the same bytes, A_t built and solved by the deviation. +`ExpectCache::with_reuse(false, false)`: the deviation before the turn, A_t built and solved by it, the same bytes; the body +persona's event peak is then 89,990 B, at the turn's solve (inside `expect` 69,332 B), at the cost of A_t's second build and solve. Silicon numbers (the same calls on the target's engine thread) are measured by the firmware and will follow; the x86_64 proxy and an Xtensa build differ in frame layout, so read the proxy as the shape and the ratio, not the target's bytes. diff --git a/probbit-persona/src/strand.rs b/probbit-persona/src/strand.rs index 3c16a3c..0245bd8 100644 --- a/probbit-persona/src/strand.rs +++ b/probbit-persona/src/strand.rs @@ -149,15 +149,17 @@ impl Live { let inputs = Json::Obj(kv); enter(Phase::Expect); // RFC 0009: the deviation and the trajectory are read from the state before the event (effects {}: the turn is as without - // them). The trajectory before the turn (nothing of the turn held while it solves), the deviation after it: with the - // cache on the turn hands it the event's own program (A_t) and its exact answer + // them). The trajectory before the turn (nothing of the turn held while it solves). With the cache reusing the turn's own + // program (the default) the deviation runs after the turn, which hands it A_t and its exact answer; else before it, as + // before (A_t built and solved by the deviation, nothing of the turn held) let traj = persona::trajectory(&self.p, &self.st, &inputs); let want = self.expect.is_some() && self.expect_cache.reuses(); + let early = if want { None } else { let cache = &mut self.expect_cache; self.expect.as_ref().and_then(|m| persona::deviation_at(&self.p, &self.st, &inputs, m, eng, cache, None)) }; let (mut stance, ns, at) = persona::turn_at(&self.p, &self.st, &inputs, eng, want)?; let sd = persona::stance_sha(&stance); enter(Phase::Expect); let cache = &mut self.expect_cache; - let dev = self.expect.as_ref().and_then(|m| persona::deviation_at(&self.p, &self.st, &inputs, m, eng, cache, at)); + let dev = if want { self.expect.as_ref().and_then(|m| persona::deviation_at(&self.p, &self.st, &inputs, m, eng, cache, at)) } else { early }; if self.expect.is_some() { if let Json::Obj(v) = &mut stance { v.push(("deviation".into(), dev.as_ref().map_or(Json::Null, |d| d.to_json(self.dev_full)))); } } // §2.18: the glyph reads the finished document, `deviation` included (the surprise modifier) persona::set_glyph(&self.p, &mut stance); From 3161a96416140fd40f1dabeedec72a3d9d60e597 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 17:29:51 -0700 Subject: [PATCH 32/61] probbit-persona: the members before the turn, their marginals held as rows (deviation_pre / deviation_post): with the cache reusing the turn's program every member but A_t is solved from the pre-event state before the turn (nothing of the turn held; the skeleton read from the first member's own text, E_t last), each member's exact marginals kept as f64 rows in the order of its layout (named again after the turn from A_t's layout, checked by the layout's sha256), then the turn, then A_t's mixture; the cache keeps only this event's rows through the turn; same bytes (new test the_members_before_the_turn_move_no_byte: reuse on with A_t checked, the old order and the cache off, 500 events, sensed and fixture personas). Proxy, the body persona: event heap peak 88,844 B (was 97,583), inside expect 72,568 B; with_reuse(false, false) unchanged (89,990 / 69,332) --- probbit-cli/src/live.rs | 32 +++++++++++ probbit-persona/src/persona.rs | 99 ++++++++++++++++++++++++++++++++++ probbit-persona/src/strand.rs | 12 +++-- 3 files changed, 139 insertions(+), 4 deletions(-) diff --git a/probbit-cli/src/live.rs b/probbit-cli/src/live.rs index 147b387..40457ae 100644 --- a/probbit-cli/src/live.rs +++ b/probbit-cli/src/live.rs @@ -557,6 +557,38 @@ mod tests { assert_eq!(verify(&on.0, &run).unwrap().get("events"), Some(&Json::Num(500.0)), "{ctx}"); } } + /// T3-c: the members before the turn move no byte. The same lives run with the cache reusing the turn's program (the members + /// but A_t solved before the turn and held as rows, A_t's mixture after it; A_t checked against a second build and solve on + /// every event), with the old order (`with_reuse(false, false)`: the whole deviation before the turn) and with the cache off + /// (the reference): every stance document, every strand line (a checkpoint every 10) and the final state are the same, A_t + /// is read from the turn and members are solved as the model. The sensed persona (floats that move every event, two needs, + /// drives, a habit on a flag), 500 events, max_combos 6 and 16, engines 0.13.0 and 0.14.0; the expectation fixture persona, + /// 500 varied events, max_combos 16 + #[test] + fn the_members_before_the_turn_move_no_byte() { + let load = |rel: &str| probbit_persona::yaml::load(&std::fs::read_to_string(format!("{}/../{rel}", env!("CARGO_MANIFEST_DIR"))).unwrap()).unwrap_or_else(|e| panic!("{}", e.msg)); + let sensed = |t: usize| { let h = (t as u64).wrapping_mul(2654435761) >> 7; let b = |k: u64| (h >> k) & 1 == 1; + let needs = if b(9) && b(10) { r#","needs":{"context":0.2}"# } else { "" }; + json::parse(&format!(r#"{{"quiet":{},"senses":{{"tokens":{}}},"src":"env:host","elapsed_hours":{}{needs}}}"#, b(2) && b(5), ((h >> 11) % 997) as f64 / 1000.0, [0.1, 0.5, 1.0][((h >> 15) % 3) as usize])).unwrap() }; + let mut cases: Vec<(Json, &str, usize)> = vec![]; + for mc in [6usize, 16] { for engine in ["probbit 0.13.0", "probbit 0.14.0"] { let mut d = load("bench/body/fixtures/needs2.yaml"); + put(&mut d, &["expectation"], json::parse(&format!(r#"{{"max_combos":{mc}}}"#)).unwrap()); cases.push((d, engine, 0)); } } + let mut d = load("probbit-cli/tests/fixtures/monitor/tutor-expect.yaml"); put(&mut d, &["expectation"], json::parse(r#"{"max_combos":16}"#).unwrap()); cases.push((d, "probbit 0.14.0", 1)); + for (d, engine, kind) in &cases { let p = persona::build(d).unwrap_or_else(|e| panic!("{}: {}", e.path, e.msg)); + let ev = |t: usize| if *kind == 0 { sensed(t) } else { s1_event(t) }; + let life = |cache: persona::ExpectCache| { let (mut lv, head) = Live::start(p.clone(), d, persona::init(&p, Some(2), true, &run), Clock::Fixed, engine); + lv.expect_cache = cache; let (mut text, mut docs, mut reused, mut modeled) = (format!("{head}\n"), vec![], 0u32, 0u32); + for t in 0..500 { let (s, l) = lv.event(&ev(t), &run).unwrap_or_else(|e| panic!("event {t}: {}: {}", e.path, e.msg)); text += &l; text.push('\n'); docs.push(persona::canon(&s)); + reused += lv.expect_cache.reused(); modeled += lv.expect_cache.modeled(); if lv.n % 10 == 0 { text += &lv.checkpoint(&s); text.push('\n'); } } + (text, docs, lv.st.digest.clone(), reused, modeled) }; + let (new, old, off) = (life(persona::ExpectCache::new(true).with_reuse(true, true)), life(persona::ExpectCache::new(true).with_reuse(false, false)), life(persona::ExpectCache::new(false))); + let ctx = format!("{engine}, {}", persona::canon(d.get("expectation").unwrap())); + for (x, what) in [(&old, "the old order"), (&off, "the cache off")] { + assert_eq!(new.1, x.1, "every stance document vs {what} ({ctx})"); assert_eq!(new.0, x.0, "every strand byte vs {what} ({ctx})"); assert_eq!(new.2, x.2, "the final state vs {what} ({ctx})"); } + assert!(new.3 > 0 && old.3 == 0 && new.4 > 0, "A_t read from the turn: new {} old {}; members as models {} ({ctx})", new.3, old.3, new.4); + assert_eq!(verify(&new.0, &run).unwrap().get("events"), Some(&Json::Num(500.0)), "{ctx}"); } + } + /// S2 (docs/persona.md §5.7, "Resume from a record"): a life recorded unbroken, and the same life resumed by each path: the /// boxed replay (every stance document and the individual it reaches), a record 1, 5 and 49 lines behind the strand /// (`resume_from_record`: the trailing event lines applied, not solved), and the embedded resume from the header, each diff --git a/probbit-persona/src/persona.rs b/probbit-persona/src/persona.rs index b22e607..192f951 100644 --- a/probbit-persona/src/persona.rs +++ b/probbit-persona/src/persona.rs @@ -4117,6 +4117,8 @@ impl ExpectCache { } fn begin(&mut self) { self.hits = 0; self.solves = 0; self.modeled = 0; self.reused = 0; self.used.clear(); self.used.resize(self.rows.len(), false); self.live = self.on && self.skip == 0; self.had = !self.rows.is_empty(); if self.skip > 0 { self.skip -= 1; } } + /// Only the rows this event has used so far are kept (`deviation_pre`, before the turn) + fn prune(&mut self) { let mut i = 0; self.rows.retain(|_| { i += 1; self.used[i - 1] }); self.used.clear(); self.used.resize(self.rows.len(), true); } fn end(&mut self) { // an event that had rows to compare and found none backs off; one that started empty only fills (the next one compares) if self.live { if self.hits > 0 { self.back = 0; } else if self.had { self.back = (self.back * 2).clamp(1, 32); self.skip = self.back; } } @@ -4282,6 +4284,103 @@ fn members_marg(p: &Persona, st: &State, raw: &Json, ins: &[Json], eng: Engine, out.push(m); } Some(out) } +/// T3-c: one member's exact marginals held through the turn: as a row of f64 in the order of the members' layout (`Held::lay`), +/// or by name when its layout is another +enum HeldM { Row(Vec), Named(Marg) } +/// T3-c: what the deviation holds through the turn when the members run before it (`deviation_pre`): per member but A_t (E_t, +/// then the other members in the mixture's order) its marginals or None (it does not compile or is not solved exactly); the +/// sha256 of the rows' layout (each variable's id and its levels' names, in order); the skeleton's key and the key of its rules +/// in force (A_t's row goes into the cache under the skeleton when its rules are the skeleton's) +pub struct Held { ms: Vec>, lay: [u8; 32], skel: Option<[u8; 32]>, rk: [u8; 32] } +/// The sha256 of a marginals' layout: per variable its id and its levels' names, in order +fn lay_key(m: &Marg) -> [u8; 32] { + let mut h = Sha256::new(); h.update(&(m.len() as u64).to_le_bytes()); + for (id, ls) in m { hash_str(&mut h, id); h.update(&(ls.len() as u64).to_le_bytes()); for (l, _) in ls { hash_str(&mut h, l); } } + h.finish() +} +/// The sha256 of a program's rules in force +fn rules_key(rules: &[Vec]) -> [u8; 32] { + let mut h = Sha256::new(); h.update(&(rules.len() as u64).to_le_bytes()); + for r in rules { hash_json(&mut h, &Json::Arr(r.clone())); } + h.finish() +} +/// T3-c, the members before the turn (`Live::event` with the cache reusing the turn's program): every member but A_t is solved +/// from the pre-event state as `deviation_cached` solves it, nothing of the turn held; their marginals are kept as rows (`Held`) +/// for `deviation_post`, which reads A_t from the turn. The members' one model is read from the first member's own text (its +/// skeleton); a member with other rules in force is built and run by the engine. E_t comes last, so the skeleton is a remembered +/// combo's when there is one (the body's combos share their rules, E_t has others). None: no deviation (no block, or E_t has +/// no exact marginals); `deviation_post` runs either way (it closes the cache's event) +#[inline(never)] +pub fn deviation_pre(p: &Persona, st: &State, raw: &Json, mem: &ExpectMem, eng: Engine, cache: &mut ExpectCache) -> Option> { + cache.begin(); let h = pre_in(p, st, raw, mem, eng, cache); + // only this event's rows are held through the turn (A_t's goes in after it) + cache.prune(); h +} +#[inline(always)] +fn pre_in(p: &Persona, st: &State, raw: &Json, mem: &ExpectMem, eng: Engine, cache: &mut ExpectCache) -> Option> { + let ex = p.expect.as_ref()?; let kv = raw.as_obj()?; + let e = get(kv, "elapsed_hours").cloned().unwrap_or(Json::Num(0.0)); let key = combo_key(p, kv); + let ws = expect_weights(ex, mem); + let mut ins = vec![with_elapsed(&Json::Obj(vec![]), &e)]; + for (k, _) in &ws { if *k == key || k == "{}" { continue; } ins.push(with_elapsed(&mem.combos.iter().find(|x| x.0 == *k).map(|x| x.2.clone()).unwrap_or(Json::Obj(vec![])), &e)); } + drop(ws); + let mut out: Vec> = (0..ins.len()).map(|_| None).collect(); + let (mut sk, mut base, mut skel): (Option>>, Option>, Option<[u8; 32]>) = (None, None, None); + let mut prog: Option = None; + for i in (1..ins.len()).chain(core::iter::once(0)) { + if sk.is_some() { prog = None; } + let t = crate::trace::Stopwatch::start(); + let m = match program_meta(p, st, &ins[i], true) { + Err(_) => { crate::trace::expect_split(t.us(), 0); None } + Ok((_, mm)) => { + let same = match &base { None => { prog = Some(member_text(p, &mm)); skel = cache.live.then(|| skel_key(prog.as_ref().unwrap())); true } Some(b) => mm.rules == b.rules }; + let (b, t) = (t.us(), crate::trace::Stopwatch::start()); + let r = if same { let am = base.as_deref().unwrap_or(&mm); model_marg(p, st, &mut prog, &mm, skel.as_ref(), &mut sk, am, false, eng, cache) } else { None }; + // other rules in force, another tier or no usable skeleton: its text from its meta, run by the engine (the members' model + // is dropped first when this is the last member) + let r = match r { Some(r) => r, None => { if i == 0 { sk = None; prog = None; } let prog = member_text(p, &mm); cache.marg(p, st, &prog, eng) } }; + if base.is_none() { base = Some(mm); } + crate::trace::expect_split(b, t.us()); r } }; + out[i] = m; } + drop((sk, prog)); + out[0].as_ref()?; + let lay = lay_key(out[0].as_ref().unwrap()); + let ms = out.into_iter().map(|m| m.map(|m| if lay_key(&m) == lay { HeldM::Row(m.iter().flat_map(|(_, ls)| ls.iter().map(|x| x.1)).collect()) } else { HeldM::Named(m) })).collect(); + Some(Box::new(Held { ms, lay, skel, rk: base.as_ref().map_or([0; 32], |b| rules_key(&b.rules)) })) +} +/// T3-c, after the turn: A_t from the turn (`at`; None: built and solved here), the held members named again from A_t's layout +/// (the same values, the same names, in the same order), then the mixture as `deviation_cached` computes it. A layout that is +/// not A_t's (never seen) solves the members again +#[inline(never)] +pub fn deviation_post(p: &Persona, st: &State, raw: &Json, mem: &ExpectMem, eng: Engine, cache: &mut ExpectCache, held: Option>, at: Option>>) -> Option { + let d = post_in(p, st, raw, mem, eng, cache, held, at); cache.end(); crate::trace::expect_cache(cache.hits, cache.solves); d +} +#[inline(always)] +#[allow(clippy::too_many_arguments)] +fn post_in(p: &Persona, st: &State, raw: &Json, mem: &ExpectMem, eng: Engine, cache: &mut ExpectCache, held: Option>, at: Option>>) -> Option { + let ex = p.expect.as_ref()?; let kv = raw.as_obj()?; let h = held?; + let a = match at { + Some(None) => { cache.reused = 1; if cache.check { assert!(program_json(p, st, raw).ok().and_then(|x| exact_marg(p, st, &x, eng)).is_none(), "A_t: not exact either way"); } return None } + Some(Some(a)) => { let At { marg, meta } = *a; cache.reused = 1; + if cache.check { at_check(p, st, raw, &member_text(p, &meta), &marg, eng); } + if let Some(s) = &h.skel { if rules_key(&meta.rules) == h.rk { cache.put(field_key(s, &meta.field), &marg); } } + marg } + None => text_marg(p, st, raw, eng, cache)? }; + let key = combo_key(p, kv); let ws = expect_weights(ex, mem); let member = ws.iter().any(|(k, _)| *k == key); + let mut ms: Vec> = Vec::with_capacity(h.ms.len() + 1); + if lay_key(&a) == h.lay { + let Held { ms: hm, .. } = *h; + for m in hm { ms.push(m.map(|m| match m { HeldM::Named(m) => m, HeldM::Row(r) => { let mut r = r.into_iter(); + a.iter().map(|(id, ls)| (id.clone(), ls.iter().map(|(l, _)| (l.clone(), r.next().unwrap_or(0.0))).collect())).collect() } })); } + } else { + drop(h); let e = get(kv, "elapsed_hours").cloned().unwrap_or(Json::Num(0.0)); + let mut ins = vec![with_elapsed(&Json::Obj(vec![]), &e)]; + for (k, _) in &ws { if *k == key || k == "{}" { continue; } ins.push(with_elapsed(&mem.combos.iter().find(|x| x.0 == *k).map(|x| x.2.clone()).unwrap_or(Json::Obj(vec![])), &e)); } + for x in &ins { ms.push(text_marg(p, st, x, eng, cache)); } + } + ms.insert(0, Some(a)); + mixture(mem, &ws, &key, member, ms) +} /// The test instrument (`ExpectCache::with_reuse(_, true)`): the A_t the turn handed is the program the deviation builds for the /// event, text for text, and its marginals are that program's run by the engine, bit for bit #[inline(never)] diff --git a/probbit-persona/src/strand.rs b/probbit-persona/src/strand.rs index 0245bd8..4278faf 100644 --- a/probbit-persona/src/strand.rs +++ b/probbit-persona/src/strand.rs @@ -150,16 +150,20 @@ impl Live { enter(Phase::Expect); // RFC 0009: the deviation and the trajectory are read from the state before the event (effects {}: the turn is as without // them). The trajectory before the turn (nothing of the turn held while it solves). With the cache reusing the turn's own - // program (the default) the deviation runs after the turn, which hands it A_t and its exact answer; else before it, as - // before (A_t built and solved by the deviation, nothing of the turn held) + // program (the default) the members but A_t run before the turn (their marginals held as rows, nothing of the turn held) + // and A_t's mixture after it, which hands A_t and its exact answer; else the whole deviation before it, as before (A_t + // built and solved by the deviation) let traj = persona::trajectory(&self.p, &self.st, &inputs); let want = self.expect.is_some() && self.expect_cache.reuses(); - let early = if want { None } else { let cache = &mut self.expect_cache; self.expect.as_ref().and_then(|m| persona::deviation_at(&self.p, &self.st, &inputs, m, eng, cache, None)) }; + let (early, held) = { let cache = &mut self.expect_cache; match self.expect.as_ref() { + Some(m) if want => (None, persona::deviation_pre(&self.p, &self.st, &inputs, m, eng, cache)), + Some(m) => (persona::deviation_at(&self.p, &self.st, &inputs, m, eng, cache, None), None), + None => (None, None) } }; let (mut stance, ns, at) = persona::turn_at(&self.p, &self.st, &inputs, eng, want)?; let sd = persona::stance_sha(&stance); enter(Phase::Expect); let cache = &mut self.expect_cache; - let dev = if want { self.expect.as_ref().and_then(|m| persona::deviation_at(&self.p, &self.st, &inputs, m, eng, cache, at)) } else { early }; + let dev = if want { self.expect.as_ref().and_then(|m| persona::deviation_post(&self.p, &self.st, &inputs, m, eng, cache, held, at)) } else { early }; if self.expect.is_some() { if let Json::Obj(v) = &mut stance { v.push(("deviation".into(), dev.as_ref().map_or(Json::Null, |d| d.to_json(self.dev_full)))); } } // §2.18: the glyph reads the finished document, `deviation` included (the surprise modifier) persona::set_glyph(&self.p, &mut stance); From 0a6404fdab56b9312660ef1517845ef21e86eb38 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 17:45:17 -0700 Subject: [PATCH 33/61] probbit-persona: E_t and every member on the model path: a member whose rules in force differ from the skeleton's takes the members' one model with its own caps (its rules lowered as run::from_json lowers them, from its text without the pairs: member_caps; run::Members::set_caps rebuilds the model on the same parts, moved; the pairs and folds stay shared; keyed by the skeleton, its rules and its field); the skeleton is read without any program text (the first member's text without pairs, the persona's couplings read as from_json reads the text's: persona_pairs); E_t first again (no other member is solved when E_t has no exact answer). Same bytes (the T2, T3, T3-b and T3-c byte tests; the fixture's and sensed lives: 0 members built and run, the old order 442 to 5,543). Proxy, the body persona: inside expect 55,920 B (deviation's own: no program text, no answer document), event peak 88,844 B, turn stack 13,552 B; recorded day: 0 members on the engine's path (253), expect 64.1% -> 57.9% of the turn, per-member solve 71.0 -> 41.6 us --- probbit-cli/src/live.rs | 13 ++-- probbit-persona/src/persona.rs | 112 ++++++++++++++++++++++++++------- probbit-persona/src/run.rs | 17 +++++ 3 files changed, 116 insertions(+), 26 deletions(-) diff --git a/probbit-cli/src/live.rs b/probbit-cli/src/live.rs index 40457ae..4128378 100644 --- a/probbit-cli/src/live.rs +++ b/probbit-cli/src/live.rs @@ -561,7 +561,8 @@ mod tests { /// but A_t solved before the turn and held as rows, A_t's mixture after it; A_t checked against a second build and solve on /// every event), with the old order (`with_reuse(false, false)`: the whole deviation before the turn) and with the cache off /// (the reference): every stance document, every strand line (a checkpoint every 10) and the final state are the same, A_t - /// is read from the turn and members are solved as the model. The sensed persona (floats that move every event, two needs, + /// is read from the turn and members are solved as the model (with other rules in force than the skeleton's too: their rules + /// lowered into caps). The sensed persona (floats that move every event, two needs, /// drives, a habit on a flag), 500 events, max_combos 6 and 16, engines 0.13.0 and 0.14.0; the expectation fixture persona, /// 500 varied events, max_combos 16 #[test] @@ -577,15 +578,19 @@ mod tests { for (d, engine, kind) in &cases { let p = persona::build(d).unwrap_or_else(|e| panic!("{}: {}", e.path, e.msg)); let ev = |t: usize| if *kind == 0 { sensed(t) } else { s1_event(t) }; let life = |cache: persona::ExpectCache| { let (mut lv, head) = Live::start(p.clone(), d, persona::init(&p, Some(2), true, &run), Clock::Fixed, engine); - lv.expect_cache = cache; let (mut text, mut docs, mut reused, mut modeled) = (format!("{head}\n"), vec![], 0u32, 0u32); + lv.expect_cache = cache; let (mut text, mut docs, mut reused, mut modeled, mut texted) = (format!("{head}\n"), vec![], 0u32, 0u32, 0u32); for t in 0..500 { let (s, l) = lv.event(&ev(t), &run).unwrap_or_else(|e| panic!("event {t}: {}: {}", e.path, e.msg)); text += &l; text.push('\n'); docs.push(persona::canon(&s)); - reused += lv.expect_cache.reused(); modeled += lv.expect_cache.modeled(); if lv.n % 10 == 0 { text += &lv.checkpoint(&s); text.push('\n'); } } - (text, docs, lv.st.digest.clone(), reused, modeled) }; + let (m, (_, sv)) = (lv.expect_cache.modeled(), lv.expect_cache.last()); reused += lv.expect_cache.reused(); modeled += m; texted += sv - m; + if lv.n % 10 == 0 { text += &lv.checkpoint(&s); text.push('\n'); } } + (text, docs, lv.st.digest.clone(), reused, modeled, texted) }; let (new, old, off) = (life(persona::ExpectCache::new(true).with_reuse(true, true)), life(persona::ExpectCache::new(true).with_reuse(false, false)), life(persona::ExpectCache::new(false))); let ctx = format!("{engine}, {}", persona::canon(d.get("expectation").unwrap())); for (x, what) in [(&old, "the old order"), (&off, "the cache off")] { assert_eq!(new.1, x.1, "every stance document vs {what} ({ctx})"); assert_eq!(new.0, x.0, "every strand byte vs {what} ({ctx})"); assert_eq!(new.2, x.2, "the final state vs {what} ({ctx})"); } assert!(new.3 > 0 && old.3 == 0 && new.4 > 0, "A_t read from the turn: new {} old {}; members as models {} ({ctx})", new.3, old.3, new.4); + // the fixture's members have other habits in force than A_t: with their rules lowered into caps they take the model too + eprintln!("T3-c members ({ctx}): modeled new {} old {}, built and run new {} old {}", new.4, old.4, new.5, old.5); + if *kind == 1 { assert!(new.4 > old.4 && new.5 < old.5, "rules into caps: modeled new {} old {}, built and run new {} old {} ({ctx})", new.4, old.4, new.5, old.5); } assert_eq!(verify(&new.0, &run).unwrap().get("events"), Some(&Json::Num(500.0)), "{ctx}"); } } diff --git a/probbit-persona/src/persona.rs b/probbit-persona/src/persona.rs index 192f951..1bb482c 100644 --- a/probbit-persona/src/persona.rs +++ b/probbit-persona/src/persona.rs @@ -2608,7 +2608,7 @@ fn compile(p: &Persona, st: &State, raw: &[(String, Json)], o: &Opts) -> R<(Json let use_twin = o.twin.unwrap_or(p.eng.twin); let ids: Vec = active.iter().map(|h| h.id.clone()).collect(); // the text before the meta, as the compiler always wrote it (the meta's fields move in after) - let prog = if o.meta_only { Json::Null } else { emit(p, &field, &rules, &ids, (st.seed, st.turn), use_twin, &sem, &o.clamps) }; + let prog = if o.meta_only { Json::Null } else { emit(p, &field, &rules, &ids, (st.seed, st.turn), use_twin, &sem, &o.clamps, true) }; let conditional = active.iter().filter(|h| !h.when.is_empty()).map(|h| h.id.clone()).collect(); let meta = Meta { vals, hist, ignored, active: ids, conditional, active_inputs, rules, owners, new_mood, new_learned, contrib, field, values: sem, twin: use_twin, lift, learn_sign, seed: st.seed, turn: st.turn }; @@ -2619,7 +2619,9 @@ fn compile(p: &Persona, st: &State, raw: &[(String, Json)], o: &Opts) -> R<(Json /// Inlined in `compile` (its frame as before); a member's text is written in a frame of its own (`member_text`) #[inline(always)] #[allow(clippy::too_many_arguments)] -fn emit(p: &Persona, field: &[Vec], rules: &[Vec], active: &[String], (seed, turn): (u64, u64), use_twin: bool, sem: &[String], clamps: &[(String, String)]) -> Json { +/// `with_pairs: false`: no `pairs` (T3-c: a member's rules read by `run::from_json_parts`, the pairs are the skeleton's) +#[allow(clippy::too_many_arguments)] +fn emit(p: &Persona, field: &[Vec], rules: &[Vec], active: &[String], (seed, turn): (u64, u64), use_twin: bool, sem: &[String], clamps: &[(String, String)], with_pairs: bool) -> Json { let (nv, ns) = (p.vars.len(), p.steps.len()); let pos_vals = p.eng.positional; let lmax = p.vars.iter().map(|v| v.levels.len()).max().unwrap_or(0); @@ -2637,7 +2639,7 @@ fn emit(p: &Persona, field: &[Vec], rules: &[Vec], active: &[String], ("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 { + for (a, b, tab) in p.couplings.iter().filter(|_| with_pairs) { 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)); } @@ -4227,7 +4229,17 @@ fn mixture(mem: &ExpectMem, ws: &[(String, f64)], key: &str, member: bool, ms: V } /// A member's program text from its compile meta (no clamps), in a frame of its own #[inline(never)] -fn member_text(p: &Persona, mm: &Meta) -> Json { emit(p, &mm.field, &mm.rules, &mm.active, (mm.seed, mm.turn), mm.twin, &mm.values, &[]) } +fn member_text(p: &Persona, mm: &Meta) -> Json { emit(p, &mm.field, &mm.rules, &mm.active, (mm.seed, mm.turn), mm.twin, &mm.values, &[], true) } +/// T3-c: a member's text without its pairs (`emit`, `with_pairs: false`), in a frame of its own +#[inline(never)] +fn member_stub(p: &Persona, mm: &Meta) -> Json { emit(p, &mm.field, &mm.rules, &mm.active, (mm.seed, mm.turn), mm.twin, &mm.values, &[], false) } +/// T3-c: a member's rules in force lowered to caps as `run::from_json` lowers its text's (its text without the pairs, read), +/// with the program's variables and values; None: not read +#[inline(never)] +fn member_caps(p: &Persona, mm: &Meta) -> Option<(Vec, Vec, Vec)> { + let t = member_stub(p, mm); + crate::run::from_json_parts(&t).ok().map(crate::run::Parts::into_caps) +} /// A program's exact marginals the reference way: its text built (`program_json`) and run by the engine (`ExpectCache::marg`); /// turn_trace: the build and the solve timed apart (`expect_build_us`, `expect_solve_us`) #[inline(never)] @@ -4238,7 +4250,9 @@ fn text_marg(p: &Persona, st: &State, ins: &Json, eng: Engine, cache: &mut Expec /// The event's own program read once from its text (`run::from_json_parts`) into the members' one model (`run::Members`: its /// parts moved in, no second copy), its variables, values and own `h`, with per variable of the persona (traits and moods, then /// the agenda steps) the value index of each of its levels in the program -struct Skel { vars: Vec, values: Vec, h0: Vec, mm: crate::run::Members, idx: Vec> } +struct Skel { vars: Vec, values: Vec, h0: Vec, mm: crate::run::Members, idx: Vec>, + /// T3-c: the key of the skeleton's rules in force, of the rules whose caps the model holds now, and the skeleton's caps + base_rk: [u8; 32], cur_rk: [u8; 32], base_caps: Vec } /// The expectation's programs -> per program its exact marginals (None: it does not compile or is not solved exactly); None when /// A_t or E_t has none (no deviation). With the cache off (`persona::deviation`, the reference) every program's text is built /// and run by the engine. With it on, A_t's text is built and read once; every other program is compiled without its text @@ -4265,7 +4279,7 @@ fn members_marg(p: &Persona, st: &State, raw: &Json, ins: &[Json], eng: Engine, let mut sk: Option>> = None; // read on the first program the cache does not answer; Some(None): not usable let a = match own { Some(m) => { if cache.check { at_check(p, st, raw, prog.as_ref().unwrap(), &m, eng); } if let Some(s) = &skel { cache.put(field_key(s, &meta.field), &m); } Some(m) } - None => model_marg(p, st, &mut prog, &meta, skel.as_ref(), &mut sk, &meta, true, eng, cache).flatten() }; + None => model_marg(p, st, &mut prog, &meta, skel.as_ref(), &mut sk, &meta, true, None, eng, cache).flatten() }; crate::trace::expect_split(b, t.us()); a.as_ref()?; out.push(a); for (i, x) in ins.iter().enumerate() { @@ -4275,7 +4289,7 @@ fn members_marg(p: &Persona, st: &State, raw: &Json, ins: &[Json], eng: Engine, let m = match program_meta(p, st, x, true) { Err(_) => { crate::trace::expect_split(t.us(), 0); None } Ok((_, mm)) if mm.rules == meta.rules => { let (b, t) = (t.us(), crate::trace::Stopwatch::start()); - let r = model_marg(p, st, &mut prog, &mm, skel.as_ref(), &mut sk, &meta, false, eng, cache); + let r = model_marg(p, st, &mut prog, &mm, skel.as_ref(), &mut sk, &meta, false, None, eng, cache); // another tier (or no usable skeleton): its text from its meta, run by the engine let r = match r { Some(r) => r, None => { let prog = member_text(p, &mm); cache.marg(p, st, &prog, eng) } }; crate::trace::expect_split(b, t.us()); r } // other rules in force: its text from its meta (no second build), run by the engine @@ -4293,12 +4307,14 @@ enum HeldM { Row(Vec), Named(Marg) } /// in force (A_t's row goes into the cache under the skeleton when its rules are the skeleton's) pub struct Held { ms: Vec>, lay: [u8; 32], skel: Option<[u8; 32]>, rk: [u8; 32] } /// The sha256 of a marginals' layout: per variable its id and its levels' names, in order +#[inline(never)] fn lay_key(m: &Marg) -> [u8; 32] { let mut h = Sha256::new(); h.update(&(m.len() as u64).to_le_bytes()); for (id, ls) in m { hash_str(&mut h, id); h.update(&(ls.len() as u64).to_le_bytes()); for (l, _) in ls { hash_str(&mut h, l); } } h.finish() } /// The sha256 of a program's rules in force +#[inline(never)] fn rules_key(rules: &[Vec]) -> [u8; 32] { let mut h = Sha256::new(); h.update(&(rules.len() as u64).to_le_bytes()); for r in rules { hash_json(&mut h, &Json::Arr(r.clone())); } @@ -4307,9 +4323,10 @@ fn rules_key(rules: &[Vec]) -> [u8; 32] { /// T3-c, the members before the turn (`Live::event` with the cache reusing the turn's program): every member but A_t is solved /// from the pre-event state as `deviation_cached` solves it, nothing of the turn held; their marginals are kept as rows (`Held`) /// for `deviation_post`, which reads A_t from the turn. The members' one model is read from the first member's own text (its -/// skeleton); a member with other rules in force is built and run by the engine. E_t comes last, so the skeleton is a remembered -/// combo's when there is one (the body's combos share their rules, E_t has others). None: no deviation (no block, or E_t has -/// no exact marginals); `deviation_post` runs either way (it closes the cache's event) +/// skeleton); a member with other rules in force takes it with its own caps (its rules lowered, `member_caps`: the pairs and +/// folds are the skeleton's); a member the model does not answer is built and run by the engine. E_t comes first, the skeleton +/// is its. None: no deviation (no block, or E_t has no exact marginals: the other members are then not solved); +/// `deviation_post` runs either way (it closes the cache's event) #[inline(never)] pub fn deviation_pre(p: &Persona, st: &State, raw: &Json, mem: &ExpectMem, eng: Engine, cache: &mut ExpectCache) -> Option> { cache.begin(); let h = pre_in(p, st, raw, mem, eng, cache); @@ -4327,20 +4344,25 @@ fn pre_in(p: &Persona, st: &State, raw: &Json, mem: &ExpectMem, eng: Engine, cac let mut out: Vec> = (0..ins.len()).map(|_| None).collect(); let (mut sk, mut base, mut skel): (Option>>, Option>, Option<[u8; 32]>) = (None, None, None); let mut prog: Option = None; - for i in (1..ins.len()).chain(core::iter::once(0)) { + for i in 0..ins.len() { if sk.is_some() { prog = None; } let t = crate::trace::Stopwatch::start(); let m = match program_meta(p, st, &ins[i], true) { Err(_) => { crate::trace::expect_split(t.us(), 0); None } Ok((_, mm)) => { - let same = match &base { None => { prog = Some(member_text(p, &mm)); skel = cache.live.then(|| skel_key(prog.as_ref().unwrap())); true } Some(b) => mm.rules == b.rules }; + // the skeleton from the first member: its text without the pairs (read with the persona's couplings), no program text + let same = match &base { None => { prog = Some(member_stub(p, &mm)); + skel = cache.live.then(|| stub_key(p, prog.as_ref().unwrap())); true } Some(b) => mm.rules == b.rules }; let (b, t) = (t.us(), crate::trace::Stopwatch::start()); - let r = if same { let am = base.as_deref().unwrap_or(&mm); model_marg(p, st, &mut prog, &mm, skel.as_ref(), &mut sk, am, false, eng, cache) } else { None }; - // other rules in force, another tier or no usable skeleton: its text from its meta, run by the engine (the members' model - // is dropped first when this is the last member) - let r = match r { Some(r) => r, None => { if i == 0 { sk = None; prog = None; } let prog = member_text(p, &mm); cache.marg(p, st, &prog, eng) } }; + // other rules in force: the same model with the member's caps (its rules lowered) + let rk = (!same).then(|| rules_key(&mm.rules)); + let r = { let am = base.as_deref().unwrap_or(&mm); model_marg(p, st, &mut prog, &mm, skel.as_ref(), &mut sk, am, false, rk.as_ref(), eng, cache) }; + // another tier, rules not read or no usable skeleton: its text from its meta, run by the engine + let r = match r { Some(r) => r, None => { let prog = member_text(p, &mm); cache.marg(p, st, &prog, eng) } }; if base.is_none() { base = Some(mm); } crate::trace::expect_split(b, t.us()); r } }; + // E_t without exact marginals: no deviation (the other members are not solved) + if i == 0 && m.is_none() { return None; } out[i] = m; } drop((sk, prog)); out[0].as_ref()?; @@ -4395,12 +4417,15 @@ fn at_check(p: &Persona, st: &State, raw: &Json, prog: &Json, m: &Marg, eng: Eng /// Some(marginals or None) as the engine would answer; None when the enumeration would not answer it (the caller runs it) #[inline(never)] #[allow(clippy::too_many_arguments)] -fn model_marg(p: &Persona, st: &State, prog: &mut Option, mm: &Meta, skel: Option<&[u8; 32]>, sk: &mut Option>>, am: &Meta, own: bool, eng: Engine, cache: &mut ExpectCache) -> Option> { - let key = skel.map(|s| field_key(s, &mm.field)); +fn model_marg(p: &Persona, st: &State, prog: &mut Option, mm: &Meta, skel: Option<&[u8; 32]>, sk: &mut Option>>, am: &Meta, own: bool, rk: Option<&[u8; 32]>, eng: Engine, cache: &mut ExpectCache) -> Option> { + // T3-c: a member with other rules in force than the skeleton's (`rk`, its rules' key) is keyed with them + let key = skel.map(|s| match rk { None => field_key(s, &mm.field), Some(r) => field_key(&rules_skel(s, r), &mm.field) }); if let Some(k) = &key { if let Some(m) = cache.find(k) { return Some(Some(m)); } } // A_t's text is read into its parts and dropped before the members' model is built from them (A_t itself, unhanded, keeps // its text: its own fallback runs it) if sk.is_none() { *sk = Some(if own { prog.as_ref().and_then(|x| skel_parts(p, x, am)) } else { prog.take().and_then(|x| skel_parts_owned(p, x, am)) }); } + // T3-c: the model's caps are the member's rules lowered (the skeleton's again for a member with its rules) + if let Some(Some(s)) = sk.as_ref() { let want = rk.copied().unwrap_or(s.base_rk); if want != s.cur_rk && !swap_caps(p, sk, mm, want) { return None; } } let own_run = |prog: &Option, cache: &mut ExpectCache| prog.as_ref().and_then(|x| cache.marg(p, st, x, eng)); let Some(Some(s)) = sk.as_mut() else { return if own { Some(own_run(prog, cache)) } else { None } }; let h = if own { s.h0.clone() } else { field_h(s, &mm.field) }; @@ -4410,13 +4435,51 @@ fn model_marg(p: &Persona, st: &State, prog: &mut Option, mm: &Meta, skel: let mut l: Vec<(String, f64)> = (0..k).filter(|&q| m.allowed[i * k + q]).map(|q| (s.values[q].clone(), mg[i * k + q])).collect(); l.sort_by(|a, b| a.0.cmp(&b.0)); l })).collect() }); cache.modeled += 1; cache.keep(key, &r); Some(r) } +/// T3-c: the members' model with the caps of the rules `want` (the skeleton's, or member `mm`'s lowered) -> false when they are +/// not read (the member takes the engine's path); the skeleton is dropped when `Model::new` refuses them +#[inline(never)] +fn swap_caps(p: &Persona, sk: &mut Option>>, mm: &Meta, want: [u8; 32]) -> bool { + let Some(Some(s)) = sk.as_mut() else { return false }; + let r = if want == s.base_rk { s.mm.set_caps(s.base_caps.clone(), true) } else { + let Some((vs, vals, caps)) = member_caps(p, mm) else { return false }; if vs != s.vars || vals != s.values { return false; } s.mm.set_caps(caps, false) }; + if r.is_err() { *sk = Some(None); return false; } + s.cur_rk = want; true +} /// A_t's text read into its parts, and the value index of every level -> None when the parts do not have the shape the compiler /// writes (then every program is run the reference way); checked: A_t's own field mapped to `h` is the `h` its text carries #[inline(never)] fn skel_parts(p: &Persona, prog: &Json, mm: &Meta) -> Option> { skel_of(p, crate::run::from_json_parts(prog).ok()?, mm) } -/// `skel_parts`, the text dropped once its parts are read +/// `skel_parts`, the text dropped once its parts are read; T3-c: a text written without its pairs (`emit`, `with_pairs: false`) +/// gets the persona's couplings as `from_json` reads them from the full text (`persona_pairs`) +#[inline(never)] +fn skel_parts_owned(p: &Persona, prog: Json, mm: &Meta) -> Option> { + let bare = prog.get("pairs").is_none(); let mut parts = crate::run::from_json_parts(&prog).ok()?; drop(prog); + if bare { let ps = persona_pairs(p, &parts.vars, &parts.values, mm.twin)?; parts.set_pairs(ps); } + skel_of(p, parts, mm) +} +/// T3-c: the pairs `emit` writes for persona `p` (its couplings, then the twins' when the program has them), read as +/// `run::from_json_parts` reads them: per pair its variables' indices and its k x k table (r6 of the persona's, at the values' +/// indices; each weight checked as `json::weight` checks it) -> None when the text's would not read (then no skeleton) +#[inline(never)] +fn persona_pairs(p: &Persona, vars: &[String], values: &[String], twin: bool) -> Option> { + let k = values.len(); let pos_vals = p.eng.positional; + let name_of = |var: &str, val: &str| -> String { match p.var(var) { Some(v) if pos_vals => format!("l{}", v.levels.iter().position(|l| l == val).unwrap_or(0)), _ => val.to_string() } }; + let vi = |id: &str| vars.iter().position(|x| x == id); let vpos = |v: &str| values.iter().position(|x| x == v); + let mut out = vec![]; + let mut tabs = vec![]; + for (a, b, tab) in &p.couplings { + let (la, lb) = (&p.var(a)?.levels, &p.var(b)?.levels); let mut t = 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))? * k + vpos(&name_of(b, l2))?] = w; } } } + if t.iter().any(|x| !x.is_finite() || x.abs() > crate::json::MAX_WEIGHT) { return None; } + let (i, j) = (vi(a)?, vi(b)?); if i == j { return None; } + out.push(probbit_ir::Pair { i, j, c: probbit_ir::Coupling::Table(t.clone()) }); tabs.push((a, b, t)); } + if twin { for (a, b, t) in tabs { let (i, j) = (vi(&format!("free.{a}"))?, vi(&format!("free.{b}"))?); out.push(probbit_ir::Pair { i, j, c: probbit_ir::Coupling::Table(t) }); } } + Some(out) +} +/// T3-c: the skeleton's key from a member's text without its pairs: the persona's digest (its couplings) and the text without +/// `comment` and the vars' `h`, as `skel_key` hashes a full text #[inline(never)] -fn skel_parts_owned(p: &Persona, prog: Json, mm: &Meta) -> Option> { let parts = crate::run::from_json_parts(&prog).ok(); drop(prog); skel_of(p, parts?, mm) } +fn stub_key(p: &Persona, stub: &Json) -> [u8; 32] { let mut h = Sha256::new(); h.update(b"without pairs"); hash_str(&mut h, &p.digest); h.update(&skel_key(stub)); h.finish() } /// The skeleton from A_t's parts (`skel_parts`) fn skel_of(p: &Persona, parts: crate::run::Parts, mm: &Meta) -> Option> { let (nv, ns) = (p.vars.len(), p.steps.len()); @@ -4427,8 +4490,9 @@ fn skel_of(p: &Persona, parts: crate::run::Parts, mm: &Meta) -> Option for _ in &p.steps { idx.push(p.slots.iter().map(|l| q(l)).collect::>>()?); } let (n, k) = (parts.vars.len(), parts.values.len()); if !field_hn(n, k, &idx, &mm.field).iter().zip(parts.h()).all(|(a, b)| a.to_bits() == b.to_bits()) { return None; } - let (vars, values, h0, mm) = parts.into_members()?; - Some(Box::new(Skel { vars, values, h0, mm, idx })) + let (vars, values, h0, mm2) = parts.into_members()?; let rk = rules_key(&mm.rules); + let base_caps = mm2.m.caps.clone(); + Some(Box::new(Skel { vars, values, h0, mm: mm2, idx, base_rk: rk, cur_rk: rk, base_caps })) } /// A field (per persona variable, per level) as the program's `h` (n x k): the value index each level gets, 0 where the text /// omits a weight, the twins' rows copied @@ -4449,6 +4513,10 @@ fn skel_key(prog: &Json) -> [u8; 32] { _ => hash_json(&mut h, x) } } } h.finish() } +/// T3-c: the skeleton's key for a member with other rules in force (`rk`, its rules' key): its text is the skeleton's with its +/// own rules and `h`, so (skeleton, rules, field) names it +#[inline(never)] +fn rules_skel(skel: &[u8; 32], rk: &[u8; 32]) -> [u8; 32] { let mut h = Sha256::new(); h.update(skel); h.update(b"rules"); h.update(rk); h.finish() } /// A program's key from A_t's skeleton and its field (each number by its bits, -0 as 0) fn field_key(skel: &[u8; 32], field: &[Vec]) -> [u8; 32] { let mut h = Sha256::new(); h.update(skel); diff --git a/probbit-persona/src/run.rs b/probbit-persona/src/run.rs index 9062891..cd975f9 100644 --- a/probbit-persona/src/run.rs +++ b/probbit-persona/src/run.rs @@ -31,6 +31,11 @@ pub struct Parts { pub vars: Vec, pub values: Vec, h: Vec, impl Parts { /// The program's own `h` (n x k), as its text gives it pub fn h(&self) -> &[f64] { &self.h } + /// T3-c: the pairs of a text read without its `pairs` (the members' skeleton: the persona's couplings, read as `from_json` + /// reads the text's) + pub fn set_pairs(&mut self, pairs: Vec) { self.pairs = pairs; } + /// T3-c: the program's caps as read and lowered (before the compile pass), with its variables and values + pub fn into_caps(self) -> (Vec, Vec, Vec) { (self.vars, self.values, self.caps) } /// The program's own model: the compile pass and `Model::new` on its `h`, as `from_json` #[inline(always)] pub fn into_prog(self) -> Result { @@ -65,6 +70,18 @@ impl Members { if h.iter().any(|x| !x.is_finite()) { return Err(crate::json::value("", "h must contain only finite weights")); } self.m.h = h; Ok(()) } + /// T3-c: the model with `caps` (a member's rules lowered, `compiled`: already through the compile pass) in place of its + /// own: the pass's never-binding caps dropped as `compile_parts` drops them, then `Model::new` on the same parts, moved (no + /// copy of the pairs): the model `from_json` builds for the member's text. Err: `Model::new` refused them (the model is + /// then gone: the caller reads no member from it again) + #[inline(never)] + pub fn set_caps(&mut self, caps: Vec, compiled: bool) -> Result<(), InErr> { + let caps = if compiled { caps } else { compile_parts(self.m.k, &mut [], vec![], caps).1 }; + let m = &mut self.m; let (start, collective, cluster, cycles) = (m.start.take(), m.collective, m.cluster, m.cycles); + use core::mem::take; + let mut nm = Model::new(m.n, m.k, take(&mut m.h), take(&mut m.allowed), take(&mut m.clamp), take(&mut m.pairs), caps).map_err(|e| crate::json::value("", e))?; + nm.start = start; nm.collective = collective; nm.cluster = cluster; nm.cycles = cycles; self.m = nm; Ok(()) + } } impl Parts { /// The members' model (`Members`): this program's model built from its parts (moved, as `into_prog`: no copy of the pairs), From b60246233516e3c66b1c99d565dedb3235c71bdb Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 17:51:33 -0700 Subject: [PATCH 34/61] probbit-ir: the components split once per caps (exact_components_split, Split): the components, their kinds, the count specs and the sub-models of the components with caps or cycles do not read h, so a model that differs only in h reuses them (each sub-model's h copied in, its finite check kept; one-shot calls build and drop them as before, the same code path); probbit-persona: the members' one model keeps its split until its caps change (run::Members::enumerated). Bit for bit (new IR test one_split_over_many_h_is_bit_identical: 400 random programs, 5 h each; the T2, T3, T3-b and T3-c byte tests). Proxy, the body persona's recorded day: expect 57.9% -> 57.5% of the turn (T3-b head 64.1%), per-member solve 41.6 -> 40.5 us (T3-b 71.0); heap with the reuse unchanged (88,844 / 55,920 B), turn stack 13,552 B --- probbit-ir/src/lib.rs | 84 ++++++++++++++++++++++++++++++---- probbit-persona/src/persona.rs | 2 +- probbit-persona/src/run.rs | 16 +++++-- 3 files changed, 87 insertions(+), 15 deletions(-) diff --git a/probbit-ir/src/lib.rs b/probbit-ir/src/lib.rs index 040aebc..2bca0b4 100644 --- a/probbit-ir/src/lib.rs +++ b/probbit-ir/src/lib.rs @@ -638,7 +638,26 @@ pub fn exact_components_until(m: &Model, limit: u64, max_states: usize, hard: Op if past(hard) { return None; } deep(m.n, || components(m, limit, max_states, hard)) } +/// T3-c: `exact_components_until` with the split of a model that differs from the split's only in `h` (RFC 0009's members): +/// the components, their kinds and the sub-models of the components with caps or cycles (their pairs and caps) are the +/// split's, each sub-model's `h` copied from `m`; the same tiers on the same numbers, so the same answer bit for bit. The +/// split is built on the first call (`Split::default()`) and is the caller's to drop when the pairs, caps, allowed values or +/// clamps change +pub fn exact_components_split(m: &Model, s: &mut Split, limit: u64, max_states: usize, hard: Option) -> Option { + if past(hard) { return None; } + deep(m.n, || { if s.mems.is_empty() { *s = split(m); s.keep = true; } solve_split(m, s, limit, max_states, hard) }) +} +/// The components tier's split of a model, what does not read `h` (`exact_components_split`) +#[derive(Default)] +pub struct Split { mems: Vec>, comp_of: Vec, tree: Vec, counted: Vec>, ccaps: Vec>, fwork: f64, + /// per component with caps or cycles its sub-model, built on its first use (Some(None): `Model::new` refused it), kept for the + /// next call with `keep` (else dropped once solved, as before) + subs: Vec>>, keep: bool } fn components(m: &Model, limit: u64, max_states: usize, hard: Option) -> Option { + let mut s = split(m); solve_split(m, &mut s, limit, max_states, hard) +} +#[inline(never)] +fn split(m: &Model) -> Split { let (n, k) = (m.n, m.k); let mut par: Vec = (0..n).collect(); fn find(par: &mut [usize], mut x: usize) -> usize { while par[x] != x { par[x] = par[par[x]]; x = par[x]; } x } fn join(par: &mut [usize], a: usize, b: usize) { let (a, b) = (find(par, a), find(par, b)); if a != b { par[a.max(b)] = a.min(b); } } @@ -652,28 +671,41 @@ fn components(m: &Model, limit: u64, max_states: usize, hard: Option> = vec![vec![]; mems.len()]; for (q, p) in m.pairs.iter().enumerate() { cpairs[comp_of[p.i]].push(q); } let counted: Vec> = (0..mems.len()).map(|c| if tree(c) { None } else { count_spec(m, &mems[c], &ccaps[c], &cpairs[c]) }).collect(); - if mems.len() == 1 && !tree(0) && counted[0].is_none() { return None; } let vals = |i: usize| -> Vec { m.cand[i].iter().copied().filter(|&v| m.ok(i, v)).collect() }; let fwork: f64 = m.pairs.iter().filter(|p| tree(comp_of[p.i])).map(|p| (vals(p.i).len() * vals(p.j).len()) as f64).sum::() + (0..n).filter(|&i| tree(comp_of[i])).count() as f64 * k as f64; - if fwork > FOREST_MAX_WORK { return None; } + let tree: Vec = (0..mems.len()).map(tree).collect(); let subs = (0..mems.len()).map(|_| None).collect(); + Split { mems, comp_of, tree, counted, ccaps, fwork, subs, keep: false } +} +fn solve_split(m: &Model, s: &mut Split, limit: u64, max_states: usize, hard: Option) -> Option { + let (n, k) = (m.n, m.k); + let Split { mems, comp_of, tree, counted, ccaps, fwork, subs, keep } = s; + if mems.len() == 1 && !tree[0] && counted[0].is_none() { return None; } + let vals = |i: usize| -> Vec { m.cand[i].iter().copied().filter(|&v| m.ok(i, v)).collect() }; + if *fwork > FOREST_MAX_WORK { return None; } let mut out = CompExact { marg: vec![0.0; n * k], map: vec![0; n], map_logw: 0.0, logz: 0.0, components: mems.len(), forest: 0, enumerated: 0, frontier: 0, occupancy: 0, infeasible: false }; let mut enum_left = limit as f64; for (c, mem) in mems.iter().enumerate() { if past(hard) { return None; } - if tree(c) { out.forest += 1; + if tree[c] { out.forest += 1; let z = tree_bp(m, mem, &vals, &mut out.marg, &mut out.map); if z == f64::NEG_INFINITY { out.infeasible = true; return Some(out); } out.logz += z; continue; } if let Some(sp) = &counted[c] { out.occupancy += 1; let z = count_dp(m, mem, sp, &mut out.marg, &mut out.map); if z == f64::NEG_INFINITY { out.infeasible = true; return Some(out); } out.logz += z; continue; } // a component with caps or cycles: a sub-model of its own variables, pairs and caps, solved by the whole-program tiers - let mut loc = vec![usize::MAX; n]; for (q, &i) in mem.iter().enumerate() { loc[i] = q; } - let nn = mem.len(); let mut h = Vec::with_capacity(nn * k); let mut al = Vec::with_capacity(nn * k); - for &i in mem { h.extend_from_slice(&m.h[i * k..i * k + k]); al.extend_from_slice(&m.allowed[i * k..i * k + k]); } - let pairs: Vec = m.pairs.iter().filter(|p| comp_of[p.i] == c).map(|p| Pair { i: loc[p.i], j: loc[p.j], c: p.c.clone() }).collect(); - let caps: Vec = ccaps[c].iter().map(|&q| Cap { members: m.caps[q].members.iter().map(|&(i, v)| (loc[i], v)).collect(), limit: m.caps[q].limit, weights: m.caps[q].weights.clone() }).collect(); - let sub = Model::new(nn, k, h, al, mem.iter().map(|&i| m.clamp[i]).collect(), pairs, caps).ok()?; + // (built once per split; a later call copies its variables' `h` in) + let nn = mem.len(); + if let Some(Some(sub)) = &mut subs[c] { for (q, &i) in mem.iter().enumerate() { sub.h[q * k..q * k + k].copy_from_slice(&m.h[i * k..i * k + k]); } + if sub.h.iter().any(|x| !x.is_finite()) { return None; } } + else if subs[c].is_none() { + let mut loc = vec![usize::MAX; n]; for (q, &i) in mem.iter().enumerate() { loc[i] = q; } + let mut h = Vec::with_capacity(nn * k); let mut al = Vec::with_capacity(nn * k); + for &i in mem.iter() { h.extend_from_slice(&m.h[i * k..i * k + k]); al.extend_from_slice(&m.allowed[i * k..i * k + k]); } + let pairs: Vec = m.pairs.iter().filter(|p| comp_of[p.i] == c).map(|p| Pair { i: loc[p.i], j: loc[p.j], c: p.c.clone() }).collect(); + let caps: Vec = ccaps[c].iter().map(|&q| Cap { members: m.caps[q].members.iter().map(|&(i, v)| (loc[i], v)).collect(), limit: m.caps[q].limit, weights: m.caps[q].weights.clone() }).collect(); + subs[c] = Some(Model::new(nn, k, h, al, mem.iter().map(|&i| m.clamp[i]).collect(), pairs, caps).ok()); } + let Some(Some(sub)) = &subs[c] else { return None }; // Enumeration only where the component's RAW space fits what is left of one shared budget of `limit` (so the tier // never spends more than one whole-program enumeration: per-component node budgets on many hard components would // add up); larger components go to the frontier DP (bounded by `max_states`) or the tier declines. @@ -684,6 +716,7 @@ fn components(m: &Model, limit: u64, max_states: usize, hard: Option 0 { got = exact_frontier_until(&sub, max_states, hard).map(|f| (f.marg, f.map, f.logz)); if got.is_some() { out.frontier += 1; } } let (mg, mp, z) = got?; + if !*keep { subs[c] = None; } for (q, &i) in mem.iter().enumerate() { out.map[i] = mp[q]; out.marg[i * k..i * k + k].copy_from_slice(&mg[q * k..q * k + k]); } out.logz += z; } @@ -2380,6 +2413,39 @@ mod tests { assert!(a.marg.iter().zip(&b.marg).all(|(x, y)| x.to_bits() == y.to_bits()), "threads {t}"); assert_eq!(a.traj, b.traj); assert_eq!(a.plans, b.plans); } } + /// T3-c: one split answers models that differ only in `h` as each one's own call does, bit for bit (marginals, log Z, the MAP + /// and its log w, infeasibility, the tier counts): the random programs of the test below (forests, cycles, caps, clamps, + /// forbidden values), each split reused over five `h` + #[test] + fn one_split_over_many_h_is_bit_identical() { + let (mut compared, mut declined) = (0, 0); + for seed in 0..400u64 { + let mut r = Philox4x32::new(seed, 23); let (n, k) = (5 + r.below(6), 2 + r.below(2)); + let h0: Vec = (0..n * k).map(|_| r.f64() * 3.0 - 1.5).collect(); + let mut allowed: Vec = (0..n * k).map(|_| r.f64() < 0.8).collect(); for i in 0..n { allowed[i * k + r.below(k)] = true; } + let clamp: Vec> = (0..n).map(|i| if r.f64() < 0.1 { (0..k).find(|&v| allowed[i * k + v]) } else { None }).collect(); + let coup = |r: &mut Philox4x32| if r.f64() < 0.5 { Coupling::Potts(r.f64() * 3.0 - 1.5) } else { Coupling::Table((0..k * k).map(|_| r.f64() * 2.0 - 1.0).collect()) }; + let mut pairs = vec![]; for i in 1..n { if r.f64() < 0.75 { let j = r.below(i); pairs.push(Pair { i, j, c: coup(&mut r) }); } } + if r.f64() < 0.3 { let (i, j) = (r.below(n), r.below(n)); if i != j { pairs.push(Pair { i, j, c: coup(&mut r) }); } } + let caps: Vec = if r.f64() < 0.4 { let mut mem: Vec<(usize, usize)> = (0..n * k).filter(|_| r.f64() < 0.25).map(|q| (q / k, q % k)).collect(); mem.dedup(); + if mem.is_empty() { vec![] } else { vec![Cap { weights: vec![], limit: r.below(mem.len()), members: mem }] } } else { vec![] }; + let mut m = Model::new(n, k, h0, allowed, clamp, pairs, caps).unwrap(); let mut s = Split::default(); + for v in 0..5 { + if v > 0 { m.h = (0..n * k).map(|_| r.f64() * 4.0 - 2.0).collect(); } + let a = exact_components_split(&m, &mut s, 1 << 22, FRONTIER_MAX_STATES, None); + let fresh = Model::new(n, k, m.h.clone(), m.allowed.clone(), m.clamp.clone(), m.pairs.clone(), m.caps.clone()).unwrap(); + let b = exact_components_until(&fresh, 1 << 22, FRONTIER_MAX_STATES, None); + let (Some(a), Some(b)) = (a.as_ref(), b.as_ref()) else { assert_eq!(a.is_none(), b.is_none(), "seed {seed} h {v}: one declined"); declined += 1; continue }; + let bits = |x: &[f64]| x.iter().map(|y| y.to_bits()).collect::>(); + assert_eq!(a.infeasible, b.infeasible, "seed {seed} h {v}"); assert_eq!(bits(&a.marg), bits(&b.marg), "seed {seed} h {v}: marginals"); + assert_eq!(a.logz.to_bits(), b.logz.to_bits(), "seed {seed} h {v}: log Z"); assert_eq!(a.map, b.map, "seed {seed} h {v}: MAP"); + assert_eq!(a.map_logw.to_bits(), b.map_logw.to_bits(), "seed {seed} h {v}: MAP log w"); + assert_eq!((a.components, a.forest, a.enumerated, a.frontier, a.occupancy), (b.components, b.forest, b.enumerated, b.frontier, b.occupancy), "seed {seed} h {v}: tiers"); + compared += 1; } + } + assert!(compared > 1000, "compared {compared}, declined {declined}"); + } + /// R19 P1.3(b, c): the components + forest tier agrees with whole-program enumeration (log Z, every odds value, the MAP's /// log w, infeasibility) on random programs: forests with forbidden values and clamps, plus cyclic and capped components /// (solved by enumeration / the frontier inside the tier); and a 1,000-variable chain's log Z matches a transfer matrix. diff --git a/probbit-persona/src/persona.rs b/probbit-persona/src/persona.rs index 1bb482c..aeac901 100644 --- a/probbit-persona/src/persona.rs +++ b/probbit-persona/src/persona.rs @@ -4430,7 +4430,7 @@ fn model_marg(p: &Persona, st: &State, prog: &mut Option, mm: &Meta, skel: let Some(Some(s)) = sk.as_mut() else { return if own { Some(own_run(prog, cache)) } else { None } }; let h = if own { s.h0.clone() } else { field_h(s, &mm.field) }; if s.mm.set_h(h).is_err() { cache.keep(key, &None); return Some(None) } - let Some(r) = crate::run::enumerated(&mut s.mm.m, p.eng.exact_limit, p.eng.sweeps) else { return if own { Some(own_run(prog, cache)) } else { None } }; + let Some(r) = s.mm.enumerated(p.eng.exact_limit, p.eng.sweeps) else { return if own { Some(own_run(prog, cache)) } else { None } }; let r = r.map(|mg| { let m = &s.mm.m; let k = m.k; s.vars.iter().enumerate().filter(|(_, id)| !id.starts_with("free.")).map(|(i, id)| (id.clone(), { let mut l: Vec<(String, f64)> = (0..k).filter(|&q| m.allowed[i * k + q]).map(|q| (s.values[q].clone(), mg[i * k + q])).collect(); l.sort_by(|a, b| a.0.cmp(&b.0)); l })).collect() }); cache.modeled += 1; cache.keep(key, &r); Some(r) diff --git a/probbit-persona/src/run.rs b/probbit-persona/src/run.rs index cd975f9..5da7f3d 100644 --- a/probbit-persona/src/run.rs +++ b/probbit-persona/src/run.rs @@ -58,7 +58,9 @@ impl Parts { /// compile pass's folds (each separable table, in the pass's order: per value the row term into its first variable, then the /// column term into its second) are kept apart, so a member's model is this one with the member's `h`, the folds replayed in /// the same order: the same floats `compile_parts` makes, with no copy of the pairs and no second model held -pub struct Members { pub m: Model, folds: Vec<(usize, usize, Vec, Vec)> } +pub struct Members { pub m: Model, folds: Vec<(usize, usize, Vec, Vec)>, + /// T3-c: the components tier's split of the model (built on the first member it answers; dropped when the caps change) + split: Split } impl Members { /// The model with `h` (n x k) in place of its own, as `Parts::model_with` builds it: each weight checked as the text's, the /// folds added in the pass's order, then `Model::new`'s finite check @@ -80,8 +82,11 @@ impl Members { let m = &mut self.m; let (start, collective, cluster, cycles) = (m.start.take(), m.collective, m.cluster, m.cycles); use core::mem::take; let mut nm = Model::new(m.n, m.k, take(&mut m.h), take(&mut m.allowed), take(&mut m.clamp), take(&mut m.pairs), caps).map_err(|e| crate::json::value("", e))?; - nm.start = start; nm.collective = collective; nm.cluster = cluster; nm.cycles = cycles; self.m = nm; Ok(()) + nm.start = start; nm.collective = collective; nm.cluster = cluster; nm.cycles = cycles; self.m = nm; self.split = Split::default(); Ok(()) } + /// `enumerated` on the model, the components tier with the members' one split (`probbit_ir::exact_components_split`: the + /// members differ only in `h` while the caps stay) + pub fn enumerated(&mut self, exact_limit: u64, sweeps: usize) -> Option>> { enumerated_in(&mut self.m, Some(&mut self.split), exact_limit, sweeps) } } impl Parts { /// The members' model (`Members`): this program's model built from its parts (moved, as `into_prog`: no copy of the pairs), @@ -100,7 +105,7 @@ impl Parts { let (pairs, caps, _) = compile_parts(k, &mut h, pairs, caps); let mut m = Model::new(vars.len(), k, h, allowed, clamp, pairs, caps).ok()?; m.start = start; if m.pairs.len() + folds.len() + dropped != np { return None; } - let want: Vec = m.h.iter().map(|x| x.to_bits()).collect(); let mut s = Members { m, folds }; + let want: Vec = m.h.iter().map(|x| x.to_bits()).collect(); let mut s = Members { m, folds, split: Split::default() }; s.set_h(h0.clone()).ok()?; (s.m.h.iter().map(|x| x.to_bits()).collect::>() == want).then_some((vars, values, h0, s)) } @@ -282,13 +287,14 @@ pub fn from_json_parts(j: &Json) -> Result { /// probabilities, the marginals of the allowed values); Some(None) for an answer without an exact verdict (infeasible, or a number /// not finite); None when `run` would go on to another tier (that one declining), or would stop its exact tiers on the wall /// clock (`sweeps` 0 over the limit): the caller runs the program. -pub fn enumerated(m: &mut Model, exact_limit: u64, sweeps: usize) -> Option>> { +pub fn enumerated(m: &mut Model, exact_limit: u64, sweeps: usize) -> Option>> { enumerated_in(m, None, exact_limit, sweeps) } +fn enumerated_in(m: &mut Model, split: Option<&mut Split>, exact_limit: u64, sweeps: usize) -> Option>> { m.collective = true; m.cluster = true; m.cycles = true; // as `persona::run_program` sets them before `run` let space: f64 = (0..m.n).map(|i| m.cand_count(i) as f64).product(); let fin = |m: &Model, marg: &[f64]| (0..m.n * m.k).all(|q| !m.allowed[q] || marg[q].is_finite()); if space > exact_limit as f64 { if sweeps == 0 { return None; } - let c = exact_components_until(m, exact_limit, FRONTIER_MAX_STATES, None)?; + let c = match split { Some(s) => exact_components_split(m, s, exact_limit, FRONTIER_MAX_STATES, None)?, None => exact_components_until(m, exact_limit, FRONTIER_MAX_STATES, None)? }; if c.infeasible { return Some(None); } let ok = c.map_logw.is_finite() && c.logz.is_finite() && (c.map_logw - c.logz).exp().is_finite() && fin(m, &c.marg); return Some(ok.then_some(c.marg)); From 3e1fab07395dab41a6c966573c1d75856c71251d Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 17:51:48 -0700 Subject: [PATCH 35/61] docs: the expectation's members, the order with the reuse and what is held (docs/embedding.md: the members before the turn as rows, A_t's mixture after it, one model without program text, other rules as caps, the components split once per caps; the proxy's numbers per step, the old order's heap); CHANGELOG --- CHANGELOG.md | 9 +++++++++ docs/embedding.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce6b029..7cb4866 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project are documented here. The format follows ## Unreleased +- **The expectation's members before the turn** (docs/embedding.md "The expectation's members: the order with the reuse, and + what is held"): with the cache reusing the turn's program, every member but A_t is solved before the turn (nothing of the + turn held; `persona::deviation_pre`), its exact marginals held as f64 rows, and A_t's mixture runs after the turn + (`persona::deviation_post`). Every member takes the members' one model, read without any program text (the persona's + couplings, a text without its pairs): a member with other rules in force than E_t's takes it with its own caps + (`run::Members::set_caps`), and the components tier's split is computed once per caps and reused per member + (`probbit_ir::exact_components_split`). Every stance, strand line and digest is the same. Proxy, the body persona: event + heap peak 97,583 → 88,844 B, inside `expect` 97,583 → 55,920 B; recorded day: `expect` 64.1% → 57.5% of the turn, + 0 members on the engine's path (253). - **The expectation's members, trimmed** (docs/embedding.md "The expectation's members: what the deviation reuses"): `Live::event` runs the turn first and, with the cache on, the turn hands the deviation its first program's meta and exact marginals (`persona::turn_at`, `persona::deviation_at`), so the event's own program is compiled and solved once (when no rest diff --git a/docs/embedding.md b/docs/embedding.md index 2791229..fe5a209 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -239,6 +239,51 @@ engine's path still builds its text and the engine's answer). A host short of he `ExpectCache::with_reuse(false, false)`: the deviation before the turn, A_t built and solved by it, the same bytes; the body persona's event peak is then 89,990 B, at the turn's solve (inside `expect` 69,332 B), at the cost of A_t's second build and solve. +### The expectation's members: the order with the reuse, and what is held + +With the cache reusing the turn's program (the default), `Live::event` runs the deviation in two parts around the turn +(`persona::deviation_pre`, `persona::deviation_post`): + +1. **Before the turn** (nothing of the turn held): every member but A_t is solved from the pre-event state, E_t first (when E_t + has no exact answer there is no deviation and no other member is solved), then the remembered combos in the mixture's + order. Each member's exact marginals are kept as a row of f64 in the order of its layout (about 0.4 KB per member on the + body persona; the layout is kept only as its sha256: each variable's id and its levels' names in order). The cache keeps + only this event's rows through the turn. +2. **The turn**, which hands A_t's compile meta and exact marginals (T3-b). +3. **After the turn**: A_t's layout must hash to the held one; the rows are named again from it (the same names, values and + order), A_t's row goes into the cache, and the mixture runs as before. A layout that differs solves the members again; an + event the turn does not hand A_t for (a rest due, another op) builds and solves A_t after the turn. + +The members share one model, read without any program text: the skeleton comes from E_t's text without its pairs (a few KB), +and the pairs are the persona's couplings read as `run::from_json` reads the text's (r6 of each table entry at its values' +indices, each weight checked as the text's). A member whose rules in force differ from E_t's (other habits, rules on inputs) +takes the same model with its own caps: its rules lowered as `from_json` lowers a text's (from its text without the pairs), +the compile pass's never-binding caps dropped, `Model::new` on the same parts (`run::Members::set_caps`, no copy of the pairs); +its cache key is the skeleton's, its rules' and its field's. The components tier's split of that model (the components, their +kinds, the sub-models of the components with caps or cycles) does not read `h`, so it is computed once per caps and reused by +every member (`probbit_ir::exact_components_split`, each sub-model's `h` copied in; the same tiers on the same numbers). + +Every stance, strand line and digest is the same as with the old order and as with the cache off +(`the_members_before_the_turn_move_no_byte`; the IR test `one_split_over_many_h_is_bit_identical` for the split). + +On the proxy (cache on; the body persona: its recorded day for time, its last recorded event for heap; one node session): + +| | T3-b head | members before the turn | every member a model | the split once | +|---|---:|---:|---:|---:| +| `expect` share of the turn, recorded day | 64.1% | 63.2% | 57.9% | 57.5% | +| per member: build / solve (µs) | 80.7 / 71.0 | 71.7 / 65.7 | 66.9 / 41.6 | 67.5 / 40.5 | +| members on the engine's path, recorded day | 253 | 159 | 0 | 0 | +| event heap peak (B) | 97,583 | 88,844 | 88,844 | 88,844 | +| heap inside `expect` (B) | 97,583 | 72,568 | 55,920 | 55,920 | +| turn stack (B) | 13,584 | 13,504 | 13,552 | 13,552 | + +The event's heap peak is now the turn's solve (its own allocations as before, plus about 1.2 KB of held rows); inside `expect` +the peak is the members' (no program text, no answer document). `ExpectCache::with_reuse(false, false)` keeps the old order +(the whole deviation before the turn, A_t built and solved by it): event peak 89,990 B, inside `expect` 72,003 B, same bytes; +the reuse is the better default now on both. On the fixture persona's 500 varied events the members are solved before the +turn knows whether A_t has an exact answer, so events without one solve their members for nothing (about 3.6 programs per +event); the model path still brings that life's share from 81.7% to 74.8% (its steady life 33.2% → 19.4%). + Silicon numbers (the same calls on the target's engine thread) are measured by the firmware and will follow; the x86_64 proxy and an Xtensa build differ in frame layout, so read the proxy as the shape and the ratio, not the target's bytes. From cb5659f790023cb21cab17336eab9cf4fb6796c6 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 19:27:28 -0700 Subject: [PATCH 36/61] probbit-persona: reflect (RFC 0013, self-report): the canonical account of a window of a strand's events from its lines alone, no solve (inputs set, the expectation's surprises with z recomputed from the logged receipts and the K = 5 largest |z|, per trait the levels taken and the odds' drift, the moods' path and turning points, drives' peaks, habits bound, credit settled, learned net movement, control lines; read from the checkpoint lines in the window, absent as null), every number at 6 decimals, digest sha256 of the canonical text, 6-12 template sentences; std and nostd; probbit reflect STRAND [--last N | --since-checkpoint] [--json | --text] --- probbit-cli/src/main.rs | 31 +++ probbit-persona/src/lib.rs | 2 + probbit-persona/src/reflect.rs | 375 +++++++++++++++++++++++++++++++++ 3 files changed, 408 insertions(+) create mode 100644 probbit-persona/src/reflect.rs diff --git a/probbit-cli/src/main.rs b/probbit-cli/src/main.rs index f53b0f4..2f7b8db 100644 --- a/probbit-cli/src/main.rs +++ b/probbit-cli/src/main.rs @@ -372,6 +372,20 @@ pub(crate) static COMMANDS: &[Command] = &[ ("exit", "0 every line replays, 1 a line differs (the frame names it and shows the event before it), 2 a bad flag, a port that cannot be had, or a file that cannot be read or is not a strand.")], examples: &["probbit monitor --demo --open", "probbit monitor pip.strand --follow", "probbit monitor pip.strand --serve --port 8080"], run: monitor::cmd }, + Command { name: "reflect", aliases: &[], group: "live", summary: "RFC 0013 self-report: a canonical account of a strand's last N events, from its lines alone", + usage: &["reflect STRAND [--last N | --since-checkpoint] [--json | --text]"], + about: "The self-report (docs/persona.md §5.8): a canonical document computed from the strand's lines alone (no turn is run, nothing is solved, nothing is random): over the window, the inputs set, the expectation's surprises (each receipt's z recomputed from the logged receipts; the K = 5 largest |z| with the predicted and measured values), per trait the levels taken and the odds' drift, the moods' path with its turning points, the drives' peaks and the habits that bound (read from the checkpoint lines in the window: a window without one reports them as null), the credit settled, the learned weights' net movement and the control lines; every number at 6 decimals, `digest` = the sha256 of the canonical text, and `summary`: 6 to 12 sentences from fixed templates. `probbit live control STRAND reflect` appends it as a line (0.14.0 strands), which `live verify` recomputes.", + before: &[], + flags: &[&[ + Flag { name: "--last", arg: "N", help: "the last N events (default: every event)" }, + Flag { name: "--since-checkpoint", arg: "", help: "the events after the last checkpoint line (that checkpoint is the first sample)" }, + Flag { name: "--json", arg: "", help: "the canonical document (the default)" }, + Flag { name: "--text", arg: "", help: "the summary's sentences only, one per line" }, + ]], + subs: &[], list_subs: false, shared: false, + after: &[("exit", "0 the reflection printed, 2 a bad flag, a file that cannot be read, or a strand with no event line in the window (one {\"error\"} object on stdout).")], + examples: &["probbit reflect pip.strand --last 50 --text", "probbit reflect pip.strand --since-checkpoint"], + run: reflect_cmd }, Command { name: "decide", aliases: &[], group: "decide/run", summary: "route tasks to workers: a router document in, a JSON decision out", usage: &["decide [flags] < router.json"], about: "Route tasks to workers: a router document (workers + caps, tasks + allowed + scores, affinity; README) on stdin, a JSON decision on stdout. Exact where structure allows, else sampled with the diagnostics gate.", @@ -1022,6 +1036,23 @@ fn lint_report(args: &[String], path: &str, p: &persona::Persona, depth: usize, /// `probbit check PERSONA [--props FILE] [--strand FILE]... [--seeds LIST] [--depth N] [--hours LIST] [--json]`: one gate for CI. /// `persona lint`'s pipeline on the rules (`lint_report`), then `live verify` on every strand; exit 1 when a rule is broken, a /// contradiction is unresolved or a strand fails, else 3 when a rule is unproven, else 0 +/// The window of `--last N` / `--since-checkpoint` (RFC 0013) +fn reflect_window(args: &[String], what: &str) -> probbit_persona::reflect::Window { + use probbit_persona::reflect::Window; + let last = args.iter().position(|x| x == "--last").map(|k| args.get(k + 1).and_then(|v| v.parse::().ok()).filter(|n| *n >= 1) + .unwrap_or_else(|| fail(&format!("{what}: --last N: a whole number >= 1")))); + match (last, args.iter().any(|a| a == "--since-checkpoint")) { (Some(_), true) => fail(&format!("{what}: --last and --since-checkpoint: one window")), + (Some(n), false) => Window::Last(n), (None, true) => Window::SinceCheckpoint, (None, false) => Window::All } +} +/// `probbit reflect STRAND [--last N | --since-checkpoint] [--json | --text]` (RFC 0013) +fn reflect_cmd(args: &[String]) { + let Some(path) = args.get(1).filter(|a| !a.starts_with("--")) else { fail("reflect: the strand file: probbit reflect STRAND [--last N | --since-checkpoint]") }; + let mut a = vec!["reflect".to_string()]; a.extend(args.iter().skip(2).cloned()); let (v, w) = flags_of("reflect"); check_flags(&a, &v, &w); + if args.iter().any(|a| a == "--json") && args.iter().any(|a| a == "--text") { fail("reflect: --json or --text") } + let text = std::fs::read_to_string(path).unwrap_or_else(|e| fail(&format!("reflect: cannot read {path}: {e}"))); + let r = probbit_persona::reflect::reflect_text(&text, reflect_window(args, "reflect")).unwrap_or_else(|e| bad_input("reflect: ", e)); + if args.iter().any(|a| a == "--text") { emit(&r.summary().join("\n")) } else { emit(&r.text()) } +} fn check_cmd(args: &[String]) { // --strand may be given more than once: take its values out before the flags are checked let (mut rest, mut strands) = (vec![], vec![]); let mut i = 0; diff --git a/probbit-persona/src/lib.rs b/probbit-persona/src/lib.rs index 9199a5e..a6ce1a7 100644 --- a/probbit-persona/src/lib.rs +++ b/probbit-persona/src/lib.rs @@ -6,6 +6,7 @@ //! `evaluate` (feature `decide`): the decision-API adapter. //! - `strand`: the strand writer (`Live`: start, event, control, checkpoint and rest lines, the `prev` chain) and its checks over //! a `&str` (`Replay`, `verify*`, `read_actions`, `first_difference`), with no file I/O. +//! - `reflect`: RFC 0013, self-report: the canonical account of a window of a strand's events, from its lines alone (no solve). //! - `trace`: with feature `turn_trace`, one event's time and a host probe's bytes by phase (`Live::event`). //! - `sys`: the process's resource usage (getrusage on 64-bit Unix; `None` elsewhere). `hooks`: the host's monitor and config echo. //! @@ -29,6 +30,7 @@ pub mod evaluate; pub mod hooks; pub mod json; pub mod persona; +pub mod reflect; #[cfg(feature = "decide")] pub mod router; pub mod run; diff --git a/probbit-persona/src/reflect.rs b/probbit-persona/src/reflect.rs new file mode 100644 index 0000000..1767a8a --- /dev/null +++ b/probbit-persona/src/reflect.rs @@ -0,0 +1,375 @@ +//! RFC 0013, self-report (docs/persona.md §5.8 "Self-report"): a canonical account of a window of a strand's events, computed +//! from the strand's lines alone: no turn is run, nothing is solved, nothing is drawn at random. Per event line the reflection +//! reads what the line logs (the inputs, the expectation's receipt, the glyph); the z of each receipt is recomputed from the +//! expectation's memory, a fold of the logged receipts (`persona::expect_step`'s mean and variance, nothing else); per +//! checkpoint line in the window it reads the stance and the state the line carries (the levels taken and their odds, the +//! moods, the drives, the habits that bound, the learned weights); control, rest and reflection lines are counted. A section +//! the window has no line for is `null` (absent, not invented): a window without a checkpoint line reports no levels. +//! +//! The document (`Reflection::doc`) is canonical JSON with every number at 6 decimals and `digest` = the sha256 of its +//! canonical text without `digest`; `summary` is 6 to 12 sentences made by fixed templates from its numbers, part of the +//! document. Cost: one pass over the lines from the fold's start (the last checkpoint carrying the expectation's memory before +//! the window, or the header), each event line parsed once, each checkpoint line in the window parsed once. +use crate::json::{self, InErr, Json}; +use crate::persona::{self, r6}; +#[allow(unused_imports)] +use std::prelude::rust_2021::*; + +/// The document's format +pub const FORMAT: f64 = 1.0; +/// The surprises reported: the K events with the largest |z| +pub const TOP_K: usize = 5; +/// The turning points listed per mood (all are counted) +pub const TURNS_MAX: usize = 8; +/// The samples of a mood's path the document carries (the last ones) +pub const PATH_MAX: usize = 50; +/// The engine from which a strand may carry reflection lines +pub const REFLECT_SINCE: (u64, u64, u64) = (0, 14, 0); +/// z at or above this registers (the expectation's own threshold) +const DEV_Z: f64 = persona::DEV_Z; +/// The expectation's prior variance and variance floor (persona.rs) +const PRIOR_VAR: f64 = 0.04; +const VAR_FLOOR: f64 = 1e-4; + +/// Which events a reflection covers +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Window { All, Last(u64), SinceCheckpoint } +impl Window { + /// The window a reflection document names (its `window.last_n` and `window.since_checkpoint`) + pub fn of(doc: &Json) -> Option { + let w = doc.get("window")?; + if w.get("since_checkpoint") == Some(&Json::Bool(true)) { return Some(Window::SinceCheckpoint); } + match w.get("last_n") { Some(Json::Num(x)) if *x >= 1.0 && x.fract() == 0.0 => Some(Window::Last(*x as u64)), Some(Json::Null) => Some(Window::All), _ => None } + } +} +/// A reflection: the canonical document (with `digest`) and its digest +pub struct Reflection { pub doc: Json, pub digest: String } +impl Reflection { + /// The canonical text + pub fn text(&self) -> String { persona::canon(&self.doc) } + /// The summary's sentences + pub fn summary(&self) -> Vec { self.doc.get("summary").and_then(Json::as_arr).map_or(vec![], |v| v.iter().filter_map(|s| s.as_str().map(String::from)).collect()) } +} + +/// What kind a canonical strand line is, from its first bytes (`reflect` reads lines `verify` checks; it does not check them) +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum K { Header, Event, Control, Checkpoint, Rest, Reflection } +fn kind_of(l: &str) -> K { + if l.starts_with("{\"probbit_strand\":") { K::Header } else if l.starts_with("{\"checkpoint\":") { K::Checkpoint } else if l.starts_with("{\"at\":") { K::Control } + else if l.starts_with("{\"prev\":") { let t = l.get(80..).unwrap_or(""); if t.starts_with("\",\"reflection\":") { K::Reflection } else { K::Rest } } + else { K::Event } +} +/// True for a reflection line (canonical: `{"prev": ..., "reflection": ...}`) +pub fn is_reflection_line(l: &str) -> bool { kind_of(l) == K::Reflection } +fn bad(msg: impl Into) -> InErr { json::value("reflect", msg) } +fn n6(x: f64) -> Json { Json::Num(r6(x)) } +fn cnt(x: usize) -> Json { Json::Num(x as f64) } +fn f6(x: f64) -> String { format!("{:.6}", r6(x)) } +/// The declared ids and levels of a document's list (`traits`, `moods`) +fn declared(doc: &Json, key: &str) -> Vec<(String, Vec)> { + doc.get(key).and_then(Json::as_arr).map_or(vec![], |v| v.iter().filter_map(|t| Some((t.get("id")?.as_str()?.to_string(), + t.get("levels")?.as_arr()?.iter().filter_map(|l| l.as_str().map(String::from)).collect()))).collect()) +} +/// Where odds sit along the levels, 0 (the lowest) to 1 (the highest): the monitor's sparkline position +fn position(levels: &[String], odds: &Json) -> f64 { + let ps: Vec = levels.iter().map(|l| odds.get(l).and_then(Json::as_f64).unwrap_or(0.0)).collect(); + let (t, k) = (ps.iter().sum::(), ps.len()); + if k < 2 || t <= 0.0 { return 0.0; } + ps.iter().enumerate().map(|(i, p)| p * i as f64).sum::() / t / (k - 1) as f64 +} +/// An input counts as set when it is true or a non-zero number (the history and the sensed readings are not counted) +fn set_keys(inputs: &Json) -> Vec { + inputs.as_obj().map_or(vec![], |kv| kv.iter().filter(|(k, v)| !matches!(k.as_str(), "elapsed_hours" | "src" | "senses") + && match v { Json::Bool(b) => *b, Json::Num(x) => *x != 0.0, _ => false }).map(|(k, _)| k.clone()).collect()) +} +fn by_count(m: &[(String, usize)]) -> Vec<(String, usize)> { let mut v = m.to_vec(); v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); v } +fn bump(m: &mut Vec<(String, usize)>, k: &str) { match m.iter_mut().find(|x| x.0 == k) { Some(x) => x.1 += 1, None => m.push((k.to_string(), 1)) } } +fn obj_counts(m: &[(String, usize)]) -> Json { Json::Obj(m.iter().map(|(k, c)| (k.clone(), cnt(*c))).collect()) } +fn odds_obj(levels: &[String], o: &Json) -> Json { Json::Obj(levels.iter().map(|l| (l.clone(), n6(o.get(l).and_then(Json::as_f64).unwrap_or(0.0)))).collect()) } + +/// Per trait (and `pursue`): the levels taken, the odds at the first and last samples, the smallest and largest p of the level taken +struct Tr { id: String, levels: Vec, taken: Vec<(String, usize)>, first: Option, last: Option, pmin: f64, pmax: f64 } +/// Per mood: its position at each sample (event n, value) +struct Md { id: String, levels: Vec, path: Vec<(u64, f64)> } + +/// The reflection of the last `n` events of a strand (`None`: every event) +pub fn reflect(lines: &[&str], n: Option) -> Result { reflect_window(lines, n.map_or(Window::All, Window::Last)) } +/// The reflection of the events after the strand's last checkpoint line (the checkpoint itself is the first sample) +pub fn reflect_since_checkpoint(lines: &[&str]) -> Result { reflect_window(lines, Window::SinceCheckpoint) } +/// `reflect` over a strand's text +pub fn reflect_text(text: &str, w: Window) -> Result { reflect_window(&crate::strand::lines(text), w) } + +/// The reflection of window `w` of a strand's lines (its header first) +#[inline(never)] +pub fn reflect_window(lines: &[&str], w: Window) -> Result { + let head = json::parse(lines.first().copied().unwrap_or("")).map_err(|e| bad(format!("line 1: the header is not JSON: {}", e.msg)))?; + if kind_of(lines.first().copied().unwrap_or("")) != K::Header { return Err(bad("line 1 is not a strand header")); } + let engine = head.get("engine").and_then(Json::as_str).unwrap_or("").to_string(); + let canon = !crate::strand::engine_version(&engine).is_some_and(|v| v < crate::strand::DEV_R6_SINCE); + let doc = head.get("document").cloned().unwrap_or(Json::Null); + let kinds: Vec = lines.iter().map(|l| kind_of(l)).collect(); + let events: Vec = (1..lines.len()).filter(|&i| kinds[i] == K::Event).collect(); + // the window: [start, lines.len()) and its first event + let (start, first_ev) = match w { + Window::All => { let s = events.first().copied().ok_or_else(|| bad("the strand has no event line"))?; (s, 0) } + Window::Last(k) => { if k == 0 { return Err(bad("--last: a whole number >= 1")); } let f = events.len().saturating_sub(k as usize); + (events.get(f).copied().ok_or_else(|| bad("the strand has no event line"))?, f) } + Window::SinceCheckpoint => { let c = (1..lines.len()).rev().find(|&i| kinds[i] == K::Checkpoint).ok_or_else(|| bad("the strand has no checkpoint line"))?; + let f = events.iter().position(|&i| i > c).ok_or_else(|| bad("no event line after the last checkpoint"))?; (c, f) } + }; + let win: &[usize] = &events[first_ev..]; + // the expectation's memory before the window's first event: from the last checkpoint before it that carries the memory (a + // canonical strand: its mean and variance are the fold's, at 6 decimals), else folded from the header + let ex = doc.get("expectation"); + let d = ex.map(|e| 0.5f64.powf(1.0 / e.get("half_life_events").and_then(Json::as_f64).unwrap_or(32.0))); + let (mut mean, mut var) = (0.0f64, PRIOR_VAR); + let mut fold_from = 1; + if ex.is_some() && canon { + if let Some(c) = (1..win[0]).rev().find(|&i| kinds[i] == K::Checkpoint && lines[i].contains(",\"expect\":")) { + let j = json::parse(lines[c]).map_err(|e| bad(format!("line {}: {}", c + 1, e.msg)))?; + let m = j.get("expect").ok_or_else(|| bad(format!("line {}: the checkpoint has no expect", c + 1)))?; + mean = m.get("mean").and_then(Json::as_f64).unwrap_or(0.0); var = m.get("var").and_then(Json::as_f64).unwrap_or(PRIOR_VAR); fold_from = c + 1; + } + } + let (traits_decl, moods_decl) = (declared(&doc, "traits"), declared(&doc, "moods")); + let goals: Vec = doc.get("drives").and_then(|d| d.get("goals")).and_then(Json::as_arr).map_or(vec![], |v| v.iter().filter_map(|g| g.get("id").and_then(Json::as_str).map(String::from)).collect()); + let from: Vec = doc.get("learning").and_then(|l| l.get("from")).and_then(Json::as_arr).map_or(vec![], |v| v.iter().filter_map(|x| x.as_str().map(String::from)).collect()); + let mut trs: Vec = traits_decl.iter().map(|(id, lv)| Tr { id: id.clone(), levels: lv.clone(), taken: vec![], first: None, last: None, pmin: f64::INFINITY, pmax: f64::NEG_INFINITY }).collect(); + if !goals.is_empty() { trs.push(Tr { id: "pursue".into(), levels: goals.clone(), taken: vec![], first: None, last: None, pmin: f64::INFINITY, pmax: f64::NEG_INFINITY }); } + let mut mds: Vec = moods_decl.iter().map(|(id, lv)| Md { id: id.clone(), levels: lv.clone(), path: vec![] }).collect(); + let (mut inputs, mut habits, mut peaks): (Vec<(String, usize)>, Vec<(String, usize)>, Vec<(String, f64, u64)>) = (vec![], vec![], vec![]); + let (mut scored, mut registered, mut top): (usize, usize, Vec<(u64, f64, Json)>) = (0, 0, vec![]); + let (mut rewards, mut corrections, mut cancelled) = (0usize, 0usize, 0usize); + let (mut samples, mut drive_samples, mut learned): (usize, usize, Vec) = (0, 0, vec![]); + let mut ctl: Vec<(String, usize)> = ["erase", "pause", "resume", "retire"].iter().map(|k| (k.to_string(), 0)).collect(); + let (mut controls, mut rests, mut reflections) = (0usize, 0usize, 0usize); + let (mut first_n, mut last_n) = (0u64, 0u64); + for i in fold_from.min(start)..lines.len() { + let in_win = i >= start; + match kinds[i] { + K::Event => { + let need = in_win || ex.is_some(); + if !need { continue; } + let j = json::parse(lines[i]).map_err(|e| bad(format!("line {}: {}", i + 1, e.msg)))?; + let n = j.get("n").and_then(Json::as_f64).unwrap_or(0.0) as u64; + let dv = j.get("deviation").filter(|x| !x.is_null()); + let cold = dv.is_some_and(|x| x.get("cold") == Some(&Json::Bool(true))); + let pred = dv.and_then(|x| x.get("pred")).and_then(Json::as_f64); + if in_win { + if first_n == 0 { first_n = n; } last_n = n; + let set = set_keys(j.get("inputs").unwrap_or(&Json::Null)); + for k in &set { bump(&mut inputs, k); } + let on = |k: Option<&String>| k.is_some_and(|k| set.contains(k)); + match (on(from.first()), on(from.get(1))) { (true, true) => cancelled += 1, (true, false) => rewards += 1, (false, true) => corrections += 1, _ => {} } + if let (Some(dv), Some(p), false, true) = (dv, pred, cold, ex.is_some()) { + let z = r6(((1.0 + p).ln() - mean) / (var + VAR_FLOOR).sqrt()); + scored += 1; if z >= DEV_Z { registered += 1; } + let f = |k: &str| n6(dv.get(k).and_then(Json::as_f64).unwrap_or(0.0)); + let mut set = set.clone(); set.sort(); + let e = Json::Obj(vec![("glyph".into(), j.get("glyph").cloned().unwrap_or(Json::Null)), ("input".into(), f("input")), ("n".into(), Json::Num(n as f64)), + ("pred".into(), f("pred")), ("self".into(), f("self")), ("set".into(), Json::Arr(set.into_iter().map(Json::Str).collect())), ("z".into(), Json::Num(z))]); + top.push((n, z, e)); + top.sort_by(|a, b| b.1.abs().partial_cmp(&a.1.abs()).unwrap_or(core::cmp::Ordering::Equal).then(a.0.cmp(&b.0))); top.truncate(TOP_K); + } + } + // the fold (persona::expect_step's mean and variance): a receipt that is not cold moves them + if let (Some(d), Some(p), false) = (d, pred, cold) { + let (x, a) = ((1.0 + p).ln(), 1.0 - d); let dx = x - mean; + mean += a * dx; var = (1.0 - a) * (var + a * dx * dx); + if canon { mean = r6(mean); var = r6(var); } + } + } + K::Checkpoint if in_win => { + let j = json::parse(lines[i]).map_err(|e| bad(format!("line {}: {}", i + 1, e.msg)))?; + let n = j.get("checkpoint").and_then(Json::as_f64).unwrap_or(0.0) as u64; + let Some(st) = j.get("stance") else { continue }; + samples += 1; + for t in trs.iter_mut() { + let x = if t.id == "pursue" { st.get("pursue") } else { st.get("stance").and_then(|s| s.get(&t.id)) }; + let Some(x) = x else { continue }; + let lv = if t.id == "pursue" { x.get("goal") } else { x.get("level") }; + if let Some(l) = lv.and_then(Json::as_str) { bump(&mut t.taken, l); } + if let Some(o) = x.get("odds") { let o = odds_obj(&t.levels, o); if t.first.is_none() { t.first = Some(o.clone()); } t.last = Some(o); } + if let Some(p) = x.get("p").and_then(Json::as_f64) { t.pmin = t.pmin.min(p); t.pmax = t.pmax.max(p); } + } + for m in mds.iter_mut() { if let Some(o) = st.get("mood").and_then(|s| s.get(&m.id)).and_then(|x| x.get("odds")) { m.path.push((n, r6(position(&m.levels, o)))); } } + if let Some(Json::Obj(want)) = st.get("drives").and_then(|d| d.get("want")) { drive_samples += 1; + for (g, v) in want { let v = v.as_f64().unwrap_or(0.0); + match peaks.iter_mut().find(|x| &x.0 == g) { Some(x) => if v > x.1 { x.1 = v; x.2 = n; }, None => peaks.push((g.clone(), v, n)) } } } + if let Some(b) = st.get("habits").and_then(|h| h.get("bound")).and_then(Json::as_arr) { for h in b.iter().filter_map(Json::as_str) { bump(&mut habits, h); } } + if let Some(l) = j.get("state").and_then(|s| s.get("learned")) { learned.push(l.clone()); } + } + K::Control if in_win => { + let j = json::parse(lines[i]).map_err(|e| bad(format!("line {}: {}", i + 1, e.msg)))?; + controls += 1; if let Some(c) = j.get("control").and_then(Json::as_str) { bump(&mut ctl, c); } + } + K::Rest if in_win => rests += 1, + K::Reflection if in_win => reflections += 1, + _ => {} + } + } + // the document + let none = |b: bool, j: Json| if b { j } else { Json::Null }; + let sampled = samples > 0; + let traits = Json::Obj(trs.iter().filter(|t| !t.taken.is_empty() || t.first.is_some()).map(|t| (t.id.clone(), Json::Obj(vec![ + ("levels".into(), obj_counts(&t.taken)), ("odds_first".into(), t.first.clone().unwrap_or(Json::Null)), ("odds_last".into(), t.last.clone().unwrap_or(Json::Null)), + ("p_max".into(), if t.pmax.is_finite() { n6(t.pmax) } else { Json::Null }), ("p_min".into(), if t.pmin.is_finite() { n6(t.pmin) } else { Json::Null })]))).collect()); + let mood = |m: &Md| -> Json { + let xs: Vec = m.path.iter().map(|p| p.1).collect(); + // the turning points: the samples where the first difference changes sign (a zero difference keeps the last sign) + let (mut turns, mut sign, mut at) = (0usize, 0i8, vec![]); + for k in 1..xs.len() { let dx = xs[k] - xs[k - 1]; if dx == 0.0 { continue; } let s = if dx > 0.0 { 1 } else { -1 }; + if sign != 0 && s != sign { turns += 1; if at.len() < TURNS_MAX { at.push(Json::Num(m.path[k - 1].0 as f64)); } } sign = s; } + let p0 = m.path.len().saturating_sub(PATH_MAX); + Json::Obj(vec![("end".into(), n6(*xs.last().unwrap_or(&0.0))), ("max".into(), n6(xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max))), + ("min".into(), n6(xs.iter().cloned().fold(f64::INFINITY, f64::min))), ("path".into(), Json::Arr(m.path[p0..].iter().map(|(n, v)| Json::Arr(vec![Json::Num(*n as f64), n6(*v)])).collect())), + ("start".into(), n6(xs[0])), ("turning_points".into(), Json::Arr(at)), ("turns".into(), cnt(turns))]) + }; + let moods = Json::Obj(mds.iter().filter(|m| !m.path.is_empty()).map(|m| (m.id.clone(), mood(m))).collect()); + let drives = Json::Obj(peaks.iter().map(|(g, v, n)| (g.clone(), Json::Obj(vec![("at".into(), Json::Num(*n as f64)), ("peak".into(), n6(*v))]))).collect()); + let net = |a: &Json, b: &Json| -> Option { Some(Json::Obj(b.as_obj()?.iter().map(|(t, v)| (t.clone(), Json::Arr(v.as_arr().unwrap_or(&[]).iter().enumerate() + .map(|(k, x)| n6(x.as_f64().unwrap_or(0.0) - a.get(t).and_then(Json::as_arr).and_then(|w| w.get(k)).and_then(Json::as_f64).unwrap_or(0.0))).collect()))).collect())) }; + let netj = if learned.len() >= 2 { net(&learned[0], &learned[learned.len() - 1]) } else { None }; + let moved = netj.as_ref().and_then(Json::as_obj).map_or(0.0, |kv| kv.iter().flat_map(|(_, v)| v.as_arr().unwrap_or(&[]).iter().filter_map(Json::as_f64)).map(|x| x.abs()).sum::()); + let inputs_sorted = { let mut v = inputs.clone(); v.sort_by(|a, b| a.0.cmp(&b.0)); v }; + let events_n = win.len(); + let segment = head.get("segment").and_then(|s| s.get("index")).cloned().unwrap_or(Json::Null); + let has_learning = doc.get("learning").is_some(); + let tops: Vec = top.iter().map(|t| t.2.clone()).collect(); + let window = Json::Obj(vec![("checkpoints".into(), cnt(samples)), ("engine".into(), Json::Str(engine.clone())), ("events".into(), cnt(events_n)), + ("first".into(), Json::Num(first_n as f64)), ("last".into(), Json::Num(last_n as f64)), ("last_n".into(), match w { Window::Last(k) => Json::Num(k as f64), _ => Json::Null }), + ("lines".into(), cnt(lines.len() - start)), ("since_checkpoint".into(), Json::Bool(w == Window::SinceCheckpoint))]); + let mut kv: Vec<(String, Json)> = vec![ + ("controls".into(), Json::Obj(vec![("erase".into(), cnt(ctl[0].1)), ("pause".into(), cnt(ctl[1].1)), ("reflections".into(), cnt(reflections)), ("rests".into(), cnt(rests)), + ("resume".into(), cnt(ctl[2].1)), ("retire".into(), cnt(ctl[3].1)), ("segment".into(), segment), ("zero_credit".into(), cnt(controls))])), + ("credit".into(), none(has_learning, Json::Obj(vec![("cancelled".into(), cnt(cancelled)), ("corrections".into(), cnt(corrections)), ("rewards".into(), cnt(rewards)), ("zeroed".into(), cnt(controls))]))), + ("drives".into(), none(drive_samples > 0, drives)), + ("habits".into(), none(sampled, Json::Obj(vec![("bound".into(), obj_counts(&{ let mut v = habits.clone(); v.sort_by(|a, b| a.0.cmp(&b.0)); v })), ("samples".into(), cnt(samples))]))), + ("inputs".into(), obj_counts(&inputs_sorted)), + ("learned".into(), none(has_learning && !learned.is_empty(), Json::Obj(vec![("moved".into(), n6(moved)), ("net".into(), netj.clone().unwrap_or(Json::Null)), ("samples".into(), cnt(learned.len()))]))), + ("moods".into(), none(sampled && !mds.iter().all(|m| m.path.is_empty()), moods)), + ("probbit_reflection".into(), Json::Num(FORMAT)), + ("surprise".into(), none(ex.is_some(), Json::Obj(vec![("registered".into(), cnt(registered)), ("scored".into(), cnt(scored)), ("threshold".into(), n6(DEV_Z)), ("top".into(), Json::Arr(tops))]))), + ("traits".into(), none(sampled, traits)), + ("window".into(), window), + ]; + let doc0 = Json::Obj(kv.clone()); + let summary = sentences(&doc0, &trs, &mds); + kv.push(("summary".into(), Json::Arr(summary.into_iter().map(Json::Str).collect()))); + let digest = persona::digest_of(&persona::canon(&Json::Obj(kv.clone()))); + kv.push(("digest".into(), Json::Str(digest.clone()))); + Ok(Reflection { doc: Json::Obj(kv), digest }) +} + +/// The summary: 6 to 12 sentences, each from a fixed template filled with the document's numbers (mechanism language only) +fn sentences(d: &Json, trs: &[Tr], mds: &[Md]) -> Vec { + let g = |p: &[&str]| -> Option<&Json> { let mut x = d; for k in p { x = x.get(k)?; } Some(x) }; + let u = |p: &[&str]| g(p).and_then(Json::as_f64).unwrap_or(0.0) as u64; + let mut s = vec![]; + s.push(format!("This account covers events {} to {}: {} events and {} lines read, {} of them checkpoint samples.", u(&["window", "first"]), u(&["window", "last"]), + u(&["window", "events"]), u(&["window", "lines"]), u(&["window", "checkpoints"]))); + let ins: Vec<(String, usize)> = g(&["inputs"]).and_then(Json::as_obj).map_or(vec![], |kv| kv.iter().map(|(k, v)| (k.clone(), v.as_f64().unwrap_or(0.0) as usize)).collect()); + let ins = by_count(&ins); + s.push(if ins.is_empty() { "No input was set in this window.".to_string() } else { + let parts: Vec = ins.iter().take(3).map(|(k, c)| format!("{k} ({c} events)")).collect(); + format!("The inputs set most often were {}.", join(&parts)) }); + match g(&["surprise"]).filter(|x| !x.is_null()) { + None => s.push("This persona has no expectation block, so no surprise was scored.".into()), + Some(sp) => { + s.push(format!("The expectation scored {} events and {} of them registered as surprising (z at least {}).", u(&["surprise", "scored"]), u(&["surprise", "registered"]), f6(DEV_Z))); + if let Some(t) = sp.get("top").and_then(Json::as_arr).and_then(|v| v.first()) { let f = |k: &str| f6(t.get(k).and_then(Json::as_f64).unwrap_or(0.0)); + s.push(format!("The largest surprise was event {}: z {}, the expectation predicted {} and the event measured {}.", t.get("n").and_then(Json::as_f64).unwrap_or(0.0) as u64, f("z"), f("pred"), f("self"))); } + } + } + if u(&["window", "checkpoints"]) == 0 { + s.push("No checkpoint line falls in this window, so the levels, moods, drives and habits are not reported.".into()); + } else { + let modal: Vec = trs.iter().filter(|t| t.id != "pursue").filter_map(|t| by_count(&t.taken).first().map(|(l, c)| format!("{} {} ({})", t.id, l, c))).take(3).collect(); + if !modal.is_empty() { s.push(format!("Across the checkpoint samples the levels taken most were {}.", join(&modal))); } + // the moods named valence and pressure first, then the others, at most two sentences + let mut ms: Vec<&Md> = mds.iter().filter(|m| !m.path.is_empty()).collect(); + ms.sort_by_key(|m| match m.id.as_str() { "valence" => 0, "pressure" => 1, _ => 2 }); + for m in ms.into_iter().take(2) { let f = |k: &str| f6(g(&["moods", m.id.as_str(), k]).and_then(Json::as_f64).unwrap_or(0.0)); + s.push(format!("The mood {} moved from {} to {} on a scale of 0 to 1 (min {}, max {}, {} turning points).", m.id, f("start"), f("end"), f("min"), f("max"), u(&["moods", m.id.as_str(), "turns"]))); } + let hb: Vec<(String, usize)> = g(&["habits", "bound"]).and_then(Json::as_obj).map_or(vec![], |kv| kv.iter().map(|(k, v)| (k.clone(), v.as_f64().unwrap_or(0.0) as usize)).collect()); + s.push(match by_count(&hb).first() { None => "No habit bound in the checkpoint samples.".into(), + Some((h, c)) => format!("The habit {h} bound in {c} of {} samples, and {} habits bound in all.", u(&["window", "checkpoints"]), hb.len()) }); + if let Some(Json::Obj(dv)) = g(&["drives"]) { let mut v: Vec<(&String, f64, u64)> = dv.iter().map(|(k, x)| (k, x.get("peak").and_then(Json::as_f64).unwrap_or(0.0), x.get("at").and_then(Json::as_f64).unwrap_or(0.0) as u64)).collect(); + v.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(core::cmp::Ordering::Equal).then(a.0.cmp(b.0))); + if let Some((k, x, n)) = v.first() { s.push(format!("The strongest want was {k} at {} (event {n}).", f6(*x))); } } + } + match g(&["credit"]).filter(|x| !x.is_null()) { + None => s.push("This persona has no learning block, so no credit was settled.".into()), + Some(_) => { + s.push(format!("Feedback settled the credit {} times as reward and {} times as correction ({} cancelled, {} zeroed by control lines).", u(&["credit", "rewards"]), + u(&["credit", "corrections"]), u(&["credit", "cancelled"]), u(&["credit", "zeroed"]))); + if let Some(l) = g(&["learned"]).filter(|x| !x.is_null()) { if l.get("net").is_some_and(|n| !n.is_null()) { + s.push(format!("The learned weights moved by {} in total between the first and the last of {} samples.", f6(l.get("moved").and_then(Json::as_f64).unwrap_or(0.0)), u(&["learned", "samples"]))); } } + } + } + let c = |k: &str| u(&["controls", k]); + s.push(if c("zero_credit") == 0 { format!("No control line was applied, and {} rest lines were written.", c("rests")) } + else { format!("Control lines applied: {} pause, {} resume, {} retire and {} erase; {} rest lines were written.", c("pause"), c("resume"), c("retire"), c("erase"), c("rests")) }); + s +} +fn join(p: &[String]) -> String { + match p.len() { 0 => String::new(), 1 => p[0].clone(), n => format!("{} and {}", p[..n - 1].join(", "), p[n - 1]) } +} + +/// The reflection line after the line whose sha256 is `prev`: canonical JSON {prev, reflection} +pub fn line(prev: &str, r: &Reflection) -> String { persona::canon(&Json::Obj(vec![("prev".into(), Json::Str(prev.into())), ("reflection".into(), r.doc.clone())])) } +/// A reflection line's check (`live verify`): the document it carries is the reflection of the lines before it over the window +/// it names, byte for byte -> Ok, or what differs +pub fn check_line(before: &[&str], l: &str) -> Result<(), String> { + let j = json::parse(l).map_err(|e| format!("not JSON: {}", e.msg))?; + let doc = j.get("reflection").ok_or("no reflection")?; + let w = Window::of(doc).ok_or("the reflection names no window (window.last_n, window.since_checkpoint)")?; + let r = reflect_window(before, w).map_err(|e| format!("the reflection cannot be recomputed: {}", e.msg))?; + if persona::canon(doc) != r.text() { return Err(format!("the reflection differs from the lines before it (recomputed digest {})", r.digest)); } + Ok(()) +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + const FORBIDDEN: [&str; 4] = ["soul", "feels", "alive", "conscious"]; + fn fixture(name: &str) -> String { std::fs::read_to_string(format!("{}/../probbit-cli/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))).unwrap() } + #[test] + fn deterministic_canonical_and_lawful() { + for f in ["monitor/tutor-expect.strand", "monitor/tutor-week.strand", "strands/expectation-0.13.0.strand", "strands/sensed-credit-0.9.0-dev.strand"] { + let t = fixture(f); let ls = crate::strand::lines(&t); + for w in [Window::All, Window::Last(5), Window::Last(10_000)] { + let a = reflect_window(&ls, w).unwrap(); let b = reflect_window(&ls, w).unwrap(); + assert_eq!(a.text(), b.text(), "{f}"); assert_eq!(persona::canon(&json::parse(&a.text()).unwrap()), a.text()); + let mut no = a.doc.clone(); if let Json::Obj(v) = &mut no { v.retain(|(k, _)| k != "digest"); } + assert_eq!(persona::digest_of(&persona::canon(&no)), a.digest); + let s = a.summary(); assert!((6..=12).contains(&s.len()), "{f}: {} sentences", s.len()); + for x in &s { let low = x.to_lowercase(); for w in FORBIDDEN { assert!(!low.contains(w), "{f}: {x}"); } } + } + } + } + /// The z the fold recomputes is the z each checkpoint's stance carries (the expectation's own, at that event) + #[test] + fn z_matches_the_checkpoints() { + let t = fixture("monitor/tutor-expect.strand"); let ls = crate::strand::lines(&t); + let r = reflect_window(&ls, Window::All).unwrap(); + let mut zs: Vec<(u64, f64)> = vec![]; + for l in &ls { if l.starts_with("{\"checkpoint\":") { let j = json::parse(l).unwrap(); + if let Some(z) = j.get("stance").and_then(|s| s.get("deviation")).and_then(|d| d.get("z")).and_then(Json::as_f64) { zs.push((j.get("checkpoint").unwrap().as_f64().unwrap() as u64, z)); } } } + assert!(!zs.is_empty()); + for (n, z) in zs { let r1 = reflect_window(&ls[..ls.iter().position(|l| l.starts_with(&format!("{{\"checkpoint\":{n},"))).unwrap()], Window::Last(1)).unwrap(); + let got = r1.doc.get("surprise").and_then(|s| s.get("top")).and_then(Json::as_arr).and_then(|v| v.first()).and_then(|e| e.get("z")).and_then(Json::as_f64); + assert_eq!(got, Some(z), "event {n}"); } + assert!(r.doc.get("surprise").is_some()); + } + #[test] + fn line_checks_and_refuses_a_forgery() { + let t = fixture("monitor/tutor-expect.strand"); let ls = crate::strand::lines(&t); + let r = reflect(&ls, Some(10)).unwrap(); let l = line(&persona::digest_of(ls[ls.len() - 1]), &r); + assert!(is_reflection_line(&l)); assert!(check_line(&ls, &l).is_ok()); + let forged = l.replacen("This account covers", "This account spans", 1); + assert!(check_line(&ls, &forged).unwrap_err().contains("differs")); + let moved = l.replacen("\"last_n\":10", "\"last_n\":9", 1); + assert!(check_line(&ls, &moved).is_err()); + } +} From b8571925f9b372737e28341dc9971301f29add9a Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 19:27:28 -0700 Subject: [PATCH 37/61] probbit-persona, probbit-cli: the reflection line (RFC 0013, engine-gated 0.14.0+): live control STRAND reflect [--last N | --since-checkpoint] appends {prev, reflection} under the writer lock; live verify recomputes it from the lines before it and refuses a forged one naming the line; a strand of an engine before 0.14.0 takes none (an older engine reads the line as unsupported, exit 5); replay, chain check and resume pass over it with no state change --- probbit-cli/src/live.rs | 23 ++++++++++++++++++++++ probbit-cli/src/main.rs | 16 ++++++++++++--- probbit-cli/tests/live.rs | 37 +++++++++++++++++++++++++++++++++++ probbit-persona/src/strand.rs | 29 ++++++++++++++++++++------- 4 files changed, 95 insertions(+), 10 deletions(-) diff --git a/probbit-cli/src/live.rs b/probbit-cli/src/live.rs index 4128378..11beb7d 100644 --- a/probbit-cli/src/live.rs +++ b/probbit-cli/src/live.rs @@ -127,6 +127,29 @@ pub fn control_cmd(path: &str, what: &str, by: &str, reason: &str, at: &str) -> ("head".into(), s(&persona::digest_of(&line)))])) } +/// `probbit live control STRAND reflect [--last N | --since-checkpoint]` (RFC 0013): a host decides when. Under the writer lock, +/// the reflection of the strand's lines (no solve) is appended as one line after its last line, chained like every line -> +/// {ok, control: reflect, digest, events, line, head}. Engine-gated: refused on a strand of an engine before 0.14.0. +pub fn reflect_cmd(path: &str, w: probbit_persona::reflect::Window) -> Result { + let lock = Lock::take(path)?; + let text = std::fs::read_to_string(path).map_err(|e| perr("strand", format!("cannot read {path}: {e}")))?; + if !text.ends_with('\n') { return Err(perr("strand", "its last line is incomplete (no newline at the end)")); } + let ls = lines(&text); + let h = json::parse(ls.first().copied().unwrap_or("")).ok(); + if h.as_ref().and_then(|h| h.get("probbit_strand").and_then(Json::as_f64)) != Some(FORMAT) { return Err(perr("strand", "not a probbit strand (format 1)")); } + if engine_version(h.as_ref().and_then(|h| h.get("engine")).and_then(Json::as_str).unwrap_or("")).map_or(true, |v| v < probbit_persona::reflect::REFLECT_SINCE) { + return Err(perr("strand", "a reflection line needs a strand of engine 0.14.0 or later (`probbit reflect STRAND` reads an older one)")); } + check_chain(&text)?; + let r = probbit_persona::reflect::reflect_window(&ls, w)?; + let line = probbit_persona::reflect::line(&persona::digest_of(ls[ls.len() - 1]), &r); + if !lock.held() { return Err(locked(None, &format!("{path}.lock"))); } + append(path, &[&line])?; + let s = |x: &str| Json::Str(x.to_string()); + Ok(Json::Obj(vec![("ok".into(), Json::Bool(true)), ("control".into(), s("reflect")), ("digest".into(), s(&r.digest)), + ("events".into(), r.doc.get("window").and_then(|x| x.get("events")).cloned().unwrap_or(Json::Null)), ("line".into(), Json::Num((ls.len() + 1) as f64)), + ("head".into(), s(&persona::digest_of(&line)))])) +} + /// `probbit live control STRAND seal` (§5.7, "Segments"): a host decides when to seal. Under the writer lock, the newest segment /// of STRAND (`NAME.strand`, `NAME.0001.strand`, ...) is replayed (from its last checkpoint, else from its start); refused unless /// its last line is an event line (a seal follows an event); then the sealing checkpoint is appended to it and the next segment diff --git a/probbit-cli/src/main.rs b/probbit-cli/src/main.rs index 2f7b8db..c29d246 100644 --- a/probbit-cli/src/main.rs +++ b/probbit-cli/src/main.rs @@ -297,7 +297,8 @@ pub(crate) static COMMANDS: &[Command] = &[ usage: &["live PERSONA [--seed N | --state FILE] [--strand FILE [--label KEY=VALUE]...] [--events FILE [--watch]] [--clock real|fixed] [--checkpoint-every K] [--segment-every N]", "live PERSONA --demo week [--seed N] [--strand FILE [--label KEY=VALUE]...] [--plain]", "live verify STRAND [--from-checkpoint] [--chain] [--actions FILE] [--blocks FILE]", - "live control STRAND pause|resume|retire|erase --by WHO --reason TEXT [--at TIME]"], + "live control STRAND pause|resume|retire|erase --by WHO --reason TEXT [--at TIME]", + "live control STRAND reflect [--last N | --since-checkpoint]"], about: "A resident individual (docs/persona.md §5.7): JSONL events (one object of inputs per line) from --events FILE or stdin, one stance per event on stdout (canonical JSON). The clock stamps each event's elapsed_hours (quantised to 1e-6 h), so moods decay by their half-lives between events; feedback moves the learned deltas of a persona with a learning block (§2.8).\n--strand FILE logs the life: a header (persona document, initial state, engine version), then per event the inputs as used, the stance and state digests and the sha256 of the line before. `probbit live verify STRAND` replays it.", before: &[], flags: &[&[ @@ -319,11 +320,13 @@ pub(crate) static COMMANDS: &[Command] = &[ Flag { name: "--chain", arg: "", help: "a segment (section 5.7): verify it and walk back through the previous segment files to the first, each verified and its last line the sealed line the next one links; exit 1 at the first break (the segment file named), 6 when a previous segment file is missing" }, Flag { name: "--actions", arg: "FILE", help: "the host's action log, one {\"turn\": T, \"action\": {trait: level}} per line: compared with each turn's released levels (summary `actions`: checked, differs); exit 1 when one differs" }, Flag { name: "--blocks", arg: "FILE", help: "write each event's block (the text a host injects, RFC 0008 E: every trait and mood as `name level`, then the viability line) to FILE, one {\"block\", \"n\"} per line; replays from the header" }] }, - Sub { name: "control", args: "STRAND pause|resume|retire|erase --by WHO --reason TEXT [--at TIME] | STRAND seal", + Sub { name: "control", args: "STRAND pause|resume|retire|erase --by WHO --reason TEXT [--at TIME] | STRAND seal | STRAND reflect [--last N | --since-checkpoint]", help: "pause, resume or retire an individual, or erase its learned weights (RFC 0004: every learned weight back to 0, the status unchanged); appends a control line under the lock: while paused or retired every event is refused (exit 4); no credit crosses a control line; retire is final; `verify` replays them and reports the status. `seal` (section 5.7) seals the newest segment of STRAND: refused unless its last line is an event line, it appends the sealing checkpoint and writes the next segment file (its header links the sealed line, then the sealed line itself), and prints that file in `file`", flags: &[Flag { name: "--by", arg: "WHO", help: "who decided it: human:ID or env:ID (required, except for seal)" }, Flag { name: "--reason", arg: "TEXT", help: "why (required, except for seal)" }, - Flag { name: "--at", arg: "TIME", help: "when (default: now, UTC)" }] }, + Flag { name: "--at", arg: "TIME", help: "when (default: now, UTC)" }, + Flag { name: "--last", arg: "N", help: "reflect: the last N events (default: every event)" }, + Flag { name: "--since-checkpoint", arg: "", help: "reflect: the events after the last checkpoint line" }] }, ], list_subs: true, shared: false, after: &[("the safety kit", "(docs/persona.md §5.7) one writer per strand: `--strand` takes STRAND.lock (a second writer exits 4 and changes nothing; a lock whose process is gone is taken over). With `reward_from` in the persona (§2.10), a reward from `src: self`, without a src or from an undeclared one is refused like a bad event."), @@ -879,6 +882,13 @@ fn live_cmd(args: &[String]) { let mut a = vec!["live control".to_string()]; a.extend(args.iter().skip(4).cloned()); let (v, w) = sub_flags("live", "control").unwrap(); check_flags(&a, &v, &w); let usage = "live control: probbit live control STRAND pause|resume|retire|erase --by human:ID --reason TEXT (or STRAND seal)"; let (Some(path), Some(what)) = (args.get(2).filter(|a| !a.starts_with("--")), args.get(3).filter(|a| !a.starts_with("--"))) else { fail(usage) }; + // reflect (RFC 0013): the reflection of the lines so far, appended as one line (0.14.0 strands) + if what == "reflect" { + if let Some(f) = ["--by", "--reason", "--at"].iter().find(|f| args.iter().any(|a| a == *f)) { fail(&format!("live control STRAND reflect: {f} does not apply (a reflection is computed, not decided by someone)")) } + match live::reflect_cmd(path, reflect_window(args, "live control")) { Ok(doc) => emit(&persona::canon(&doc)), Err(e) => refuse("live control: ", e) } + return; + } + if args.iter().any(|a| a == "--last" || a == "--since-checkpoint") { fail("live control: --last and --since-checkpoint go with reflect") } // seal (§5.7): no control line, so no --by, --reason or --at; the sealing checkpoint and the next segment file if what == "seal" { if let Some(f) = ["--by", "--reason", "--at"].iter().find(|f| args.iter().any(|a| a == *f)) { fail(&format!("live control STRAND seal: {f} does not apply (a seal writes a checkpoint line, not a control line)")) } diff --git a/probbit-cli/tests/live.rs b/probbit-cli/tests/live.rs index c7bb6d3..1e00d4a 100644 --- a/probbit-cli/tests/live.rs +++ b/probbit-cli/tests/live.rs @@ -757,3 +757,40 @@ fn a_host_seals_a_strand_and_the_chain_verifies() { assert_eq!((s(&v, &["segments"]), s(&v, &["events"]), s(&v, &["final_state"])), (&Json::Num(3.0), &Json::Num(15.0), s(&parse(&vone), &["final_state"]))); let _ = std::fs::remove_dir_all(&dir); clean(&[&all, &a, &b, &st1, &one, &st2]); } + +/// RFC 0013: `probbit reflect` prints the canonical document (or its sentences); `live control STRAND reflect` appends it as a +/// line that `live verify` recomputes (ok, the events unchanged); a changed number, a changed sentence or a moved window is +/// refused naming the line (exit 1); `live` continues a strand that ends on a reflection line; a strand of 0.13.0 is refused +#[test] +fn reflection_lines_verify_and_forgeries_are_refused() { + let dir = std::env::temp_dir().join(format!("probbit-reflect-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); + let f = dir.join("r.strand").to_string_lossy().into_owned(); + let run = |args: &[&str], stdin: &str| { let mut c = Command::new(env!("CARGO_BIN_EXE_probbit")).args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + c.stdin.take().unwrap().write_all(stdin.as_bytes()).unwrap(); 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()) }; + let ev: String = (0..12).map(|k| format!("{{\"elapsed_hours\":1{}}}\n", if k % 3 == 0 { ",\"praise\":true" } else { "" })).collect(); + let st = dir.join("r.json").to_string_lossy().into_owned(); + assert_eq!(run(&["persona", "init", "../examples/persona/tutor.yaml", "--seed", "2", "--out", &st], "").0, 0); + let live = |stdin: &str| run(&["live", "../examples/persona/tutor.yaml", "--state", &st, "--clock", "fixed", "--strand", &f, "--checkpoint-every", "4"], stdin); + assert_eq!(live(&ev).0, 0); + let before = std::fs::read_to_string(&f).unwrap(); + let (c, doc, _) = run(&["reflect", &f, "--last", "6"], ""); assert_eq!(c, 0); + assert!(doc.starts_with("{\"controls\":") && doc.contains("\"digest\":\"sha256:") && doc.contains("\"summary\":[\"This account covers events 7 to 12"), "{doc}"); + let (c, text, _) = run(&["reflect", &f, "--last", "6", "--text"], ""); assert_eq!(c, 0); let n = text.lines().count(); assert!((6..=12).contains(&n), "{text}"); + assert_eq!(std::fs::read_to_string(&f).unwrap(), before, "reflect writes nothing"); + let (c, out, err) = run(&["live", "control", &f, "reflect", "--last", "6"], ""); assert_eq!(c, 0, "{out}{err}"); + let (c, v, _) = run(&["live", "verify", &f], ""); assert_eq!(c, 0, "{v}"); assert!(v.contains("\"events\":12"), "{v}"); + let text = std::fs::read_to_string(&f).unwrap(); let last = text.lines().last().unwrap().to_string(); let at = text.lines().count(); + for (what, from, to) in [("a number", "\"events\":6", "\"events\":7"), ("a sentence", "This account covers", "This account spans"), ("the window", "\"last_n\":6", "\"last_n\":5")] { + let forged = text.replacen(&last, &last.replacen(from, to, 1), 1); assert_ne!(forged, text, "{what}"); + let g = dir.join("forged.strand"); std::fs::write(&g, &forged).unwrap(); + let (c, v, _) = run(&["live", "verify", &g.to_string_lossy()], ""); assert_eq!(c, 1, "{what}: {v}"); assert!(v.contains(&format!("\"line\":{at}")), "{what}: {v}"); + } + // the life goes on after a reflection line, and the strand still verifies + assert_eq!(live("{\"elapsed_hours\":1}\n").0, 0); assert_eq!(run(&["live", "verify", &f], "").0, 0); + // engine-gated: a strand written by 0.13.0 takes no reflection line + let old = format!("{}/{STRANDS}/expectation-0.13.0.strand", env!("CARGO_MANIFEST_DIR").trim_end_matches("/probbit-cli")); let g = dir.join("old.strand"); std::fs::copy(&old, &g).unwrap(); + let (c, out, _) = run(&["live", "control", &g.to_string_lossy(), "reflect"], ""); assert_eq!(c, 2, "{out}"); assert!(out.contains("0.14.0"), "{out}"); + assert_eq!(run(&["reflect", &g.to_string_lossy()], "").0, 0, "an older strand is read"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/probbit-persona/src/strand.rs b/probbit-persona/src/strand.rs index 4278faf..6965794 100644 --- a/probbit-persona/src/strand.rs +++ b/probbit-persona/src/strand.rs @@ -266,8 +266,8 @@ fn rest_line(prev: &str, rest: Json, state: &str) -> String { persona::canon(&Json::Obj(vec![("prev".into(), Json::Str(prev.into())), ("rest".into(), rest), ("state".into(), Json::Str(state.into()))])) } /// What kind of strand line `j` is -pub enum Kind { Event, Control, Checkpoint, Rest } -pub fn kind(j: &Json) -> Kind { if j.get("control").is_some() { Kind::Control } else if j.get("checkpoint").is_some() { Kind::Checkpoint } else if j.get("rest").is_some() { Kind::Rest } else { Kind::Event } } +pub enum Kind { Event, Control, Checkpoint, Rest, Reflection } +pub fn kind(j: &Json) -> Kind { if j.get("control").is_some() { Kind::Control } else if j.get("checkpoint").is_some() { Kind::Checkpoint } else if j.get("rest").is_some() { Kind::Rest } else if j.get("reflection").is_some() { Kind::Reflection } else { Kind::Event } } /// The header's keys, as this engine writes them (a segment's header has one more at its end: `segment`) const HEADER: [&str; 6] = ["probbit_strand", "engine", "persona", "seed", "state", "document"]; /// The divergence of a segment whose second line is not the line its header links @@ -301,9 +301,11 @@ pub fn segment_header(lv: &Live, doc: &Json, engine: &str, index: u64, previous: /// gives `nap`) or a field it does not know on a known one fn unknown_key(j: &Json) -> Option<&str> { let known: &[&str] = match kind(j) { Kind::Event => &["deviation", "glyph", "inputs", "n", "prev", "stance", "state"], Kind::Control => &["at", "by", "control", "prev", "reason"], - Kind::Checkpoint => &["checkpoint", "expect", "prev", "stance", "state"], Kind::Rest => &["prev", "rest", "state"] }; + Kind::Checkpoint => &["checkpoint", "expect", "prev", "stance", "state"], Kind::Rest => &["prev", "rest", "state"], Kind::Reflection => &["prev", "reflection"] }; j.as_obj()?.iter().map(|(k, _)| k.as_str()).find(|k| !known.contains(k)) } +/// The divergence of a reflection line in a strand of an engine before 0.14.0 (RFC 0013: reflection lines are engine-gated) +pub const REFLECT_OLD: &str = "a reflection line in a strand of an engine before 0.14.0 (reflection lines need 0.14.0 or later)"; /// The start of a replay's `why` that is not a divergence: the line has a field or a line kind this engine does not know (a /// strand from a newer engine). Nothing from that line on was checked: the strand is not verified, and not shown to differ. /// `verify` reports it as `unsupported` (exit 5), never as `diverges`; a changed value of a field this engine knows still @@ -448,6 +450,9 @@ impl Replay { live.prev = persona::digest_of(l); } self.line = i; self.checkpoint_ready = false; return Ok(None) } + // RFC 0013: a reflection line changes nothing (its document is checked against the lines before it: `step_at`) + Kind::Reflection => { if live.dev_full { return Err((i, REFLECT_OLD.into())); } + live.prev = persona::digest_of(l); self.line = i; self.checkpoint_ready = false; return Ok(None) } Kind::Event => {} } if j.get("n").and_then(Json::as_f64) != Some((live.n + 1) as f64) { return Err((i, format!("n is not {}", live.n + 1))); } let ev = j.get("inputs").ok_or((i, "no inputs".to_string()))?; @@ -477,6 +482,12 @@ impl Replay { *seen = seen.min(m); if lost && lost_at.is_none() { *lost_at = Some(self.live.n); } } Ok(Some(stance)) } + /// `step` of `lines[i]`, a reflection line also checked against the lines before it (RFC 0013): every verify path steps so + pub fn step_at(&mut self, lines: &[&str], i: usize, eng: Engine) -> Result, (usize, String)> { + let r = self.step(lines[i], eng)?; + if r.is_none() && crate::reflect::is_reflection_line(lines[i]) { crate::reflect::check_line(&lines[..i], lines[i]).map_err(|why| (i + 1, why))?; } + Ok(r) + } /// The end of the strand: a rest line (RFC 0002 part 2) is only ever written with the event after it, so a strand that ends /// on one diverges at it pub fn end(&self) -> Result<(), (usize, String)> { @@ -529,7 +540,7 @@ pub fn verify_blocks(text: &str, eng: Engine) -> Result<(Json, Blocks), (usize, let lines = lines(text); let replay = || -> Result<(Json, Blocks), (usize, String)> { let (mut r, k) = Replay::open_lines(&lines)?; let mut out = vec![]; - for l in lines.iter().skip(k) { if let Some(doc) = r.step(l, eng)? { out.push((r.live.n, persona::block(&r.live.p, &doc))); } } + for i in k..lines.len() { if let Some(doc) = r.step_at(&lines, i, eng)? { out.push((r.live.n, persona::block(&r.live.p, &doc))); } } Ok((r.summary(), out)) }; replay().map_err(|e| chain_first(&lines, e)) } @@ -549,7 +560,7 @@ pub fn verify(text: &str, eng: Engine) -> Result { let lines = lines(text); let replay = || -> Result { let (mut r, k) = Replay::open_lines(&lines)?; - for l in lines.iter().skip(k) { r.step(l, eng)?; } + for i in k..lines.len() { r.step_at(&lines, i, eng)?; } r.end()?; Ok(r.summary()) }; replay().map_err(|e| chain_first(&lines, e)) } @@ -580,7 +591,7 @@ pub fn verify_from_checkpoint(text: &str, eng: Engine) -> Result= 2) else { return verify(text, eng) }; let replay = || -> Result { let mut r = Replay::open_at(lines[0], lines[k - 1], lines[k], k + 1)?; - for l in lines.iter().skip(k + 1) { r.step(l, eng)?; } + for i in k + 1..lines.len() { r.step_at(&lines, i, eng)?; } r.end()?; Ok(r.summary()) }; replay().map_err(|e| chain_first(&lines, e)) } @@ -589,7 +600,7 @@ pub fn verify_keep(text: &str, eng: Engine, keep: &[u64]) -> Result<(Json, Vec<( let lines = lines(text); let replay = || -> Result<(Json, Vec<(u64, Json)>, Persona), (usize, String)> { let (mut r, k) = Replay::open_lines(&lines)?; let mut kept = vec![]; - for l in lines.iter().skip(k) { if let Some(doc) = r.step(l, eng)? { + for i in k..lines.len() { if let Some(doc) = r.step_at(&lines, i, eng)? { if let Some(t) = doc.get("turn").and_then(Json::as_f64).map(|x| x as u64).filter(|t| keep.contains(t)) { kept.push((t, doc)); } } } r.end()?; Ok((r.summary(), kept, r.live.p.clone())) }; replay().map_err(|e| chain_first(&lines, e)) @@ -748,6 +759,7 @@ fn check_chain_head(text: &str) -> Result<(Status, Box, Json), InErr> { } } } + Kind::Reflection => { if !canon { return Err(err(i + 1, REFLECT_OLD.into())); } } Kind::Rest => { if status != Status::Active { return Err(err(i + 1, format!("rest line while {}", status.name()))); } if !ls.get(i + 1).and_then(|x| json::parse(x).ok()).is_some_and(|x| matches!(kind(&x), Kind::Event)) { return Err(err(i + 1, "a rest line must be followed by its event line".into())); } @@ -782,6 +794,8 @@ pub fn resume_boxed(text: &str, p: &Persona, st: Box, clock: Clock) -> Re // event or a checkpoint is only ever written while active let (mut a, mut tail) = (lines.len() - 1, vec![]); while a > 0 { let j = json::parse(lines[a]).map_err(|e| bad(format!("line {} is not JSON: {}", a + 1, e.msg)))?; + // RFC 0013: a reflection line changes nothing: the anchor is before it + if matches!(kind(&j), Kind::Reflection) { a -= 1; continue; } if !matches!(kind(&j), Kind::Control) { break; } tail.push(j); a -= 1; } let (n, want) = if a == 0 { (0, h.get("state").and_then(|s| s.get("digest")).and_then(Json::as_str).unwrap_or("").to_string()) } else { let j = json::parse(lines[a]).map_err(|e| bad(format!("line {} is not JSON: {}", a + 1, e.msg)))?; @@ -878,6 +892,7 @@ fn resume_walk>(q: Box, h: &Json, head: &str, cp: Option< if rest.is_some() && !matches!(kind(&j), Kind::Event) { return Err(err(at - 1, "a rest line must be followed by its event line".into())); } match kind(&j) { Kind::Rest => { if status != Status::Active { return Err(err(at, format!("rest line while {}", status.name()))); } rest = Some(l.to_string()); } + Kind::Reflection => { if !canon { return Err(err(at, REFLECT_OLD.into())); } } Kind::Control => { let f = |k: &str| j.get(k).and_then(Json::as_str).unwrap_or(""); status = status.after(f("control")).map_err(|e| err(at, e.msg))?; let line = control_line(f("prev"), f("control"), f("by"), f("reason"), f("at")).map_err(|e| err(at, e.msg))?; From 3fcc78da2de5c631c313af85a7fe8c53e89bee59 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 19:27:28 -0700 Subject: [PATCH 38/61] probbit-cli monitor: the Mirror tab (RFC 0013): /reflection?last=N and /s/ID/reflection serve the reflection computed from the strand file now (no solve) and the latest logged reflection line with whether it recomputes equal; the page shows the summary, the top surprises with predicted vs measured, the habits bound and the moods at the checkpoint samples, refreshed with the page's scan; test through the serve path --- probbit-cli/src/monitor.html | 18 +++++++++++++++++- probbit-cli/src/monitor.rs | 21 +++++++++++++++++++++ probbit-cli/tests/monitor.rs | 24 ++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/probbit-cli/src/monitor.html b/probbit-cli/src/monitor.html index 428661b..c361a38 100644 --- a/probbit-cli/src/monitor.html +++ b/probbit-cli/src/monitor.html @@ -25,6 +25,7 @@ .rail{flex:none;width:212px;height:100vh;position:sticky;top:0;overflow:auto;background:var(--bar);padding:16px 10px;display:flex;flex-direction:column;gap:10px}.rail h2{font-size:10.5px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;padding:0 8px;display:flex;justify-content:space-between}.rail-list{display:flex;flex-direction:column;gap:2px}.rail-item{display:flex;align-items:center;gap:10px;width:100%;text-align:left;border:0;background:none;border-radius:8px;padding:6px 8px;min-height:44px}.rail-item[aria-current=true]{background:var(--selection)}.rail-item[aria-current=true] strong{color:var(--blue)}.rail-glyph{width:22px;font-size:18px;line-height:1;text-align:center;flex:none}.rail-text{min-width:0;display:flex;flex-direction:column}.rail-text strong{font-weight:600;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.rail-text small{color:var(--muted);font-size:10.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums}.rail-keys{margin-top:auto;padding:0 8px;font-size:10px;color:var(--muted)} @media(max-width:760px){.app{flex-direction:column}.rail{width:auto;height:auto;position:static;padding:8px 10px;gap:4px}.rail-list{flex-direction:row;overflow-x:auto}.rail-item{width:auto;flex:none}.rail-keys{display:none}.who{padding:9px 12px 7px}} @media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}} +body.mirror-on .board,body.mirror-on #panel,body.mirror-on #workspace,body.mirror-on #timeline,body.mirror-on #said,body.mirror-on #diagnostics,body.mirror-on #why-panel,body.mirror-on #empty{display:none!important}.mirror{padding:14px 18px;overflow:auto}.mirror li{margin:3px 0}.mirror td,.mirror th{padding:3px 10px;text-align:left}.mirror-mood{display:flex;gap:12px;align-items:center}.mirror-mood svg{width:140px;height:25px} @@ -34,7 +35,9 @@

probbit

Activity Monitor

probbit · declared agent state
Waiting for individual
-
+
+ +
CONNECTINGWaiting for the local monitor.

Waiting for a strand.

@@ -414,6 +417,19 @@ } addEventListener('keydown',e=>{const k=e.key>='1'&&e.key<='9'&&(e.metaKey||e.ctrlKey)&&!e.altKey&&!e.shiftKey?Number(e.key)-1:-1;if(k<0||strands.length<2||!strands[k])return;e.preventDefault();pick(strands[k].id);}); $('rail-list').onkeydown=e=>{if(!['ArrowDown','ArrowUp'].includes(e.key))return;e.preventDefault();const at=strands.findIndex(s=>s.id===sid),n=strands[Math.max(0,Math.min(strands.length-1,at+(e.key==='ArrowDown'?1:-1)))];pick(n.id);[...$('rail-list').children].find(b=>b.dataset.strand===n.id)?.focus();}; +let mirrorOn=false; +function tab(on){mirrorOn=on;document.body.classList.toggle('mirror-on',on);$('mirror').hidden=!on;$('tab-board').setAttribute('aria-pressed',String(!on));$('tab-mirror').setAttribute('aria-pressed',String(on));if(on)mirror();} +$('tab-board').onclick=()=>tab(false);$('tab-mirror').onclick=()=>tab(true); +// RFC 0013: the reflection, computed by the server from the strand's lines (no solve), refreshed with the scan +async function mirror(){if(!mirrorOn)return;let j;try{const r=await fetch(base+'reflection?last=50',{cache:'no-store'});j=r.ok?await r.json():{error:(await r.text()).trim()};}catch(_){j={error:'The reflection could not be fetched.'};} + const d=obj(j.live)||{},w=obj(d.window)||{},lg=obj(j.logged); + $('mirror-note').textContent=j.error?j.error:'Events '+w.first+'–'+w.last+' · '+w.checkpoints+' checkpoint samples · digest '+short(d.digest)+(lg?' · logged reflection at line '+lg.line+(lg.matches?' recomputes equal':' differs from the lines before it'):' · no reflection line logged'); + const s=$('mirror-summary');s.replaceChildren();(d.summary||[]).forEach(x=>mk('li','',s,x)); + const t=$('mirror-top');t.replaceChildren();(get(d,'surprise','top')||[]).forEach(e=>{const r=mk('tr','',t);[e.n,f6(e.z),f6(e.pred),f6(e.self),(e.set||[]).join(', ')||'—'].forEach(v=>mk('td','number',r,String(v)));}); + const h=$('mirror-habits');h.replaceChildren();Object.entries(obj(get(d,'habits','bound'))||{}).forEach(([k,c])=>mk('span','chip',h,k+' ×'+c)); + const m=$('mirror-moods');m.replaceChildren();Object.entries(obj(d.moods)||{}).forEach(([k,v])=>{const row=mk('div','mirror-mood',m);mk('span','',row,k+' '+f2(v.start)+' → '+f2(v.end)+' · '+v.turns+' turning points');plot(svg('svg',{viewBox:'0 0 140 25',role:'img','aria-label':k+' by checkpoint sample'},row),(v.path||[]).map(([n,value])=>({n,value})),{width:140,height:25});});} +const f6=x=>num(x)?x.toFixed(6):'—'; +setInterval(mirror,2000); const safe=fn=>e=>{try{fn(JSON.parse(e.data));}catch(x){console.error(x);}}; // The demo and an older server list no strand: the single-strand stream at once list().then(()=>{if(!es&&(!listed||!strands.length))connect();});setInterval(list,2000); diff --git a/probbit-cli/src/monitor.rs b/probbit-cli/src/monitor.rs index d29ceb1..a65621c 100644 --- a/probbit-cli/src/monitor.rs +++ b/probbit-cli/src/monitor.rs @@ -1049,6 +1049,15 @@ fn handle(mut c: TcpStream, site: &Site, port: u16) { let (id, path) = match path.strip_prefix("/s/") { Some(r) => r.split_once('/').map_or((Some(r), ""), |(id, _)| (Some(id), &path[3 + id.len()..])), None => (None, path) }; if id.is_none() && path == "/" { return reply(&mut c, "200 OK", "text/html; charset=utf-8", PAGE.as_bytes()); } if id.is_none() && path == "/strands" { return reply(&mut c, "200 OK", "application/json; charset=utf-8", strands_json(site).as_bytes()); } + // RFC 0013, the Mirror tab: `/reflection?last=N` (default 50), computed from the strand file as it is now + if path == "/reflection" { + let file = match id { None => entries(site).first().map(|e| e.path.clone()), Some(id) => entries(site).iter().find(|e| e.id == id).map(|e| e.path.clone()) }; + let Some(file) = file else { return reply(&mut c, "404 Not Found", "text/plain", b"no strand file: the reflection reads a strand (the demo has none)\n") }; + let last = target.split_once('?').and_then(|(_, q)| q.split('&').find_map(|kv| kv.strip_prefix("last="))).map_or(Some(50), |v| v.parse::().ok().filter(|n| *n >= 1)); + let Some(last) = last else { return reply(&mut c, "400 Bad Request", "text/plain", b"last=N: a whole number >= 1\n") }; + return match std::fs::read_to_string(&file) { Ok(t) => reply(&mut c, "200 OK", "application/json; charset=utf-8", format!("{}\n", mirror(&t, last)).as_bytes()), + Err(_) => reply(&mut c, "404 Not Found", "text/plain", b"the strand file cannot be read\n") }; + } let Some(hub) = hub_for(site, id).filter(|_| path == "/events" || path.starts_with("/doc/")) else { return reply(&mut c, "404 Not Found", "text/plain", if id.is_some() { b"not found: /s/ID/events and /s/ID/doc/N serve the strand ID that /strands lists\n" } else { b"not found: / is the page, /events its stream, /doc/N a stance document, /strands the strands served\n" }) }; let hub = &hub; @@ -1062,6 +1071,18 @@ fn handle(mut c: TcpStream, site: &Site, port: u16) { match doc { Some(d) => reply(&mut c, "200 OK", "application/json; charset=utf-8", d.as_bytes()), None => reply(&mut c, "404 Not Found", "text/plain", msg.as_bytes()) } } _ => {} } } +/// The Mirror's document (RFC 0013): `live`, the reflection of the last `last` events computed now (no solve); `logged`, the +/// latest reflection line the strand carries (its line number, its document, and whether it is the reflection of the lines +/// before it: `matches`), or null; `error` when the strand cannot be reflected +pub fn mirror(text: &str, last: u64) -> String { + use probbit_persona::reflect; + let ls = crate::live::lines(text); let o = |v: Vec<(&str, Json)>| Json::Obj(v.into_iter().map(|(k, x)| (k.to_string(), x)).collect()); + let logged = (1..ls.len()).rev().find(|&i| reflect::is_reflection_line(ls[i])).map_or(Json::Null, |i| o(vec![("line", Json::Num((i + 1) as f64)), + ("doc", json::parse(ls[i]).ok().and_then(|j| j.get("reflection").cloned()).unwrap_or(Json::Null)), ("matches", Json::Bool(reflect::check_line(&ls[..i], ls[i]).is_ok()))])); + persona::canon(&match reflect::reflect_window(&ls, reflect::Window::Last(last)) { + Ok(r) => o(vec![("live", r.doc), ("logged", logged)]), + Err(e) => o(vec![("error", Json::Str(e.msg)), ("logged", logged)]) }) +} /// The headers every answer carries: no caching, no sniffing, and for the page a policy that lets it load nothing and connect /// to nothing but its own server const SAFE: &str = "Cache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\nReferrer-Policy: no-referrer\r\nContent-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'\r\n"; diff --git a/probbit-cli/tests/monitor.rs b/probbit-cli/tests/monitor.rs index 2c414cc..95d358d 100644 --- a/probbit-cli/tests/monitor.rs +++ b/probbit-cli/tests/monitor.rs @@ -534,3 +534,27 @@ fn a_served_life_in_segments_is_one_strand_that_follows_its_seals() { let list = get(port, "/strands", &host).2; assert!(list.contains(r#""n":7,"#) && list.contains(r#""segment":2"#) && list.matches(r#""id":"#).count() == 1, "{list}"); drop(s); drop(child); let _ = std::fs::remove_dir_all(&dir); } + +/// RFC 0013, the Mirror tab through the serve path: `/reflection?last=N` is `probbit reflect STRAND --last N` computed from the +/// file as it is now (`live`, no reflection line: `logged` null); after `live control STRAND reflect --last N` the logged line +/// is reported and recomputes equal; `/s/ID/reflection` serves strand ID; a bad `last` is 400; the page carries the tab +#[test] +fn the_mirror_serves_the_reflection() { + let f = tmp("mirror"); + let ev: String = (0..30).map(|k| format!("{{\"elapsed_hours\":1{}}}\n", if k % 4 == 0 { ",\"praise\":true" } else if k % 7 == 0 { ",\"loss\":true" } else { "" })).collect(); + lived(&f, "2", &["--checkpoint-every", "5"], &ev); + let (c, want, _) = probbit(&["reflect", &f, "--last", "10"]); assert_eq!(c, 0); let want = want.trim_end().to_string(); + let (child, port) = served(&[&f]); let host = format!("127.0.0.1:{port}"); + let (st, h, page) = get(port, "/", &host); assert_eq!(st, "HTTP/1.1 200 OK"); assert!(page.contains("id=\"tab-mirror\"") && page.contains("reflection?last=50"), "{h}"); + let (st, h, body) = get(port, "/reflection?last=10", &host); assert_eq!(st, "HTTP/1.1 200 OK"); assert!(h.contains("application/json"), "{h}"); + assert_eq!(body, format!("{{\"live\":{want},\"logged\":null}}\n")); + assert_eq!(get(port, "/reflection?last=0", &host).0, "HTTP/1.1 400 Bad Request"); + let (c, out, err) = probbit(&["live", "control", &f, "reflect", "--last", "10"]); assert_eq!(c, 0, "{out}{err}"); assert!(out.contains("\"control\":\"reflect\""), "{out}"); + let body = get(port, "/reflection?last=10", &host).2; + assert!(body.contains(&format!("\"logged\":{{\"doc\":{want},\"line\":")) && body.contains("\"matches\":true"), "{body}"); + drop(child); + let (child, port) = served(&[&f]); let host = format!("127.0.0.1:{port}"); + let id = std::path::Path::new(&f).file_stem().unwrap().to_string_lossy().replace(|c: char| !(c.is_ascii_alphanumeric() || "_.-".contains(c)), "-"); + assert_eq!(get(port, &format!("/s/{id}/reflection?last=10", ), &host).0, "HTTP/1.1 200 OK"); + drop(child); let _ = std::fs::remove_file(&f); +} From e1658162c049489f182fec9a0046db3d322dc9ae Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 20:00:47 -0700 Subject: [PATCH 39/61] probbit-persona reflect: the templates' counts in the singular for one (event, line, sample, habit, time, turning point, rest line), a drives sentence only for a want above 0; the test's list of words the summary never contains is held reversed in the source --- probbit-persona/src/reflect.rs | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/probbit-persona/src/reflect.rs b/probbit-persona/src/reflect.rs index 1767a8a..fa4160c 100644 --- a/probbit-persona/src/reflect.rs +++ b/probbit-persona/src/reflect.rs @@ -266,17 +266,17 @@ fn sentences(d: &Json, trs: &[Tr], mds: &[Md]) -> Vec { let g = |p: &[&str]| -> Option<&Json> { let mut x = d; for k in p { x = x.get(k)?; } Some(x) }; let u = |p: &[&str]| g(p).and_then(Json::as_f64).unwrap_or(0.0) as u64; let mut s = vec![]; - s.push(format!("This account covers events {} to {}: {} events and {} lines read, {} of them checkpoint samples.", u(&["window", "first"]), u(&["window", "last"]), - u(&["window", "events"]), u(&["window", "lines"]), u(&["window", "checkpoints"]))); + s.push(format!("This account covers events {} to {}: {} and {} read, {} of them checkpoint samples.", u(&["window", "first"]), u(&["window", "last"]), + pl(u(&["window", "events"]), "event", "events"), pl(u(&["window", "lines"]), "line", "lines"), u(&["window", "checkpoints"]))); let ins: Vec<(String, usize)> = g(&["inputs"]).and_then(Json::as_obj).map_or(vec![], |kv| kv.iter().map(|(k, v)| (k.clone(), v.as_f64().unwrap_or(0.0) as usize)).collect()); let ins = by_count(&ins); s.push(if ins.is_empty() { "No input was set in this window.".to_string() } else { - let parts: Vec = ins.iter().take(3).map(|(k, c)| format!("{k} ({c} events)")).collect(); + let parts: Vec = ins.iter().take(3).map(|(k, c)| format!("{k} ({})", pl(*c as u64, "event", "events"))).collect(); format!("The inputs set most often were {}.", join(&parts)) }); match g(&["surprise"]).filter(|x| !x.is_null()) { None => s.push("This persona has no expectation block, so no surprise was scored.".into()), Some(sp) => { - s.push(format!("The expectation scored {} events and {} of them registered as surprising (z at least {}).", u(&["surprise", "scored"]), u(&["surprise", "registered"]), f6(DEV_Z))); + s.push(format!("The expectation scored {} and {} of them registered as surprising (z at least {}).", pl(u(&["surprise", "scored"]), "event", "events"), u(&["surprise", "registered"]), f6(DEV_Z))); if let Some(t) = sp.get("top").and_then(Json::as_arr).and_then(|v| v.first()) { let f = |k: &str| f6(t.get(k).and_then(Json::as_f64).unwrap_or(0.0)); s.push(format!("The largest surprise was event {}: z {}, the expectation predicted {} and the event measured {}.", t.get("n").and_then(Json::as_f64).unwrap_or(0.0) as u64, f("z"), f("pred"), f("self"))); } } @@ -290,28 +290,32 @@ fn sentences(d: &Json, trs: &[Tr], mds: &[Md]) -> Vec { let mut ms: Vec<&Md> = mds.iter().filter(|m| !m.path.is_empty()).collect(); ms.sort_by_key(|m| match m.id.as_str() { "valence" => 0, "pressure" => 1, _ => 2 }); for m in ms.into_iter().take(2) { let f = |k: &str| f6(g(&["moods", m.id.as_str(), k]).and_then(Json::as_f64).unwrap_or(0.0)); - s.push(format!("The mood {} moved from {} to {} on a scale of 0 to 1 (min {}, max {}, {} turning points).", m.id, f("start"), f("end"), f("min"), f("max"), u(&["moods", m.id.as_str(), "turns"]))); } + s.push(format!("The mood {} moved from {} to {} on a scale of 0 to 1 (min {}, max {}, {}).", m.id, f("start"), f("end"), f("min"), f("max"), pl(u(&["moods", m.id.as_str(), "turns"]), "turning point", "turning points"))); } let hb: Vec<(String, usize)> = g(&["habits", "bound"]).and_then(Json::as_obj).map_or(vec![], |kv| kv.iter().map(|(k, v)| (k.clone(), v.as_f64().unwrap_or(0.0) as usize)).collect()); s.push(match by_count(&hb).first() { None => "No habit bound in the checkpoint samples.".into(), - Some((h, c)) => format!("The habit {h} bound in {c} of {} samples, and {} habits bound in all.", u(&["window", "checkpoints"]), hb.len()) }); + Some((h, c)) => format!("The habit {h} bound in {c} of {}, and {} bound in all.", pl(u(&["window", "checkpoints"]), "sample", "samples"), pl(hb.len() as u64, "habit", "habits")) }); if let Some(Json::Obj(dv)) = g(&["drives"]) { let mut v: Vec<(&String, f64, u64)> = dv.iter().map(|(k, x)| (k, x.get("peak").and_then(Json::as_f64).unwrap_or(0.0), x.get("at").and_then(Json::as_f64).unwrap_or(0.0) as u64)).collect(); v.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(core::cmp::Ordering::Equal).then(a.0.cmp(b.0))); - if let Some((k, x, n)) = v.first() { s.push(format!("The strongest want was {k} at {} (event {n}).", f6(*x))); } } + match v.first() { Some((k, x, n)) if *x > 0.0 => s.push(format!("The strongest want was {k} at {} (event {n}).", f6(*x))), + Some(_) => s.push("No drive's want rose above 0 in the checkpoint samples.".into()), None => {} } } } match g(&["credit"]).filter(|x| !x.is_null()) { None => s.push("This persona has no learning block, so no credit was settled.".into()), Some(_) => { - s.push(format!("Feedback settled the credit {} times as reward and {} times as correction ({} cancelled, {} zeroed by control lines).", u(&["credit", "rewards"]), - u(&["credit", "corrections"]), u(&["credit", "cancelled"]), u(&["credit", "zeroed"]))); + s.push(format!("Feedback settled the credit {} as reward and {} as correction ({} cancelled, {} zeroed by control lines).", pl(u(&["credit", "rewards"]), "time", "times"), + pl(u(&["credit", "corrections"]), "time", "times"), u(&["credit", "cancelled"]), u(&["credit", "zeroed"]))); if let Some(l) = g(&["learned"]).filter(|x| !x.is_null()) { if l.get("net").is_some_and(|n| !n.is_null()) { - s.push(format!("The learned weights moved by {} in total between the first and the last of {} samples.", f6(l.get("moved").and_then(Json::as_f64).unwrap_or(0.0)), u(&["learned", "samples"]))); } } + s.push(format!("The learned weights moved by {} in total between the first and the last of {}.", f6(l.get("moved").and_then(Json::as_f64).unwrap_or(0.0)), pl(u(&["learned", "samples"]), "sample", "samples"))); } } } } let c = |k: &str| u(&["controls", k]); - s.push(if c("zero_credit") == 0 { format!("No control line was applied, and {} rest lines were written.", c("rests")) } - else { format!("Control lines applied: {} pause, {} resume, {} retire and {} erase; {} rest lines were written.", c("pause"), c("resume"), c("retire"), c("erase"), c("rests")) }); + let rests = |n: u64| if n == 1 { "1 rest line was written".to_string() } else { format!("{n} rest lines were written") }; + s.push(if c("zero_credit") == 0 { format!("No control line was applied, and {}.", rests(c("rests"))) } + else { format!("Control lines applied: {} pause, {} resume, {} retire and {} erase; {}.", c("pause"), c("resume"), c("retire"), c("erase"), rests(c("rests"))) }); s } +/// `n` with the word for one or for many +fn pl(n: u64, one: &str, many: &str) -> String { format!("{n} {}", if n == 1 { one } else { many }) } fn join(p: &[String]) -> String { match p.len() { 0 => String::new(), 1 => p[0].clone(), n => format!("{} and {}", p[..n - 1].join(", "), p[n - 1]) } } @@ -332,7 +336,8 @@ pub fn check_line(before: &[&str], l: &str) -> Result<(), String> { #[cfg(all(test, feature = "std"))] mod tests { use super::*; - const FORBIDDEN: [&str; 4] = ["soul", "feels", "alive", "conscious"]; + /// The words the summary never contains (the project's language law), written reversed so the source does not carry them + const FORBIDDEN_REVERSED: [&str; 4] = ["luos", "sleef", "evila", "suoicsnoc"]; fn fixture(name: &str) -> String { std::fs::read_to_string(format!("{}/../probbit-cli/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))).unwrap() } #[test] fn deterministic_canonical_and_lawful() { @@ -344,7 +349,7 @@ mod tests { let mut no = a.doc.clone(); if let Json::Obj(v) = &mut no { v.retain(|(k, _)| k != "digest"); } assert_eq!(persona::digest_of(&persona::canon(&no)), a.digest); let s = a.summary(); assert!((6..=12).contains(&s.len()), "{f}: {} sentences", s.len()); - for x in &s { let low = x.to_lowercase(); for w in FORBIDDEN { assert!(!low.contains(w), "{f}: {x}"); } } + for x in &s { let low = x.to_lowercase(); for w in FORBIDDEN_REVERSED { let w: String = w.chars().rev().collect(); assert!(!low.contains(&w), "{f}: {x}"); } } } } } From db9368b2f25c83c9e1032a99695af923beef9cc9 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 20:00:47 -0700 Subject: [PATCH 40/61] =?UTF-8?q?docs:=20RFC=200013=20self-report=20(docs/?= =?UTF-8?q?rfcs/0013-self-report.md,=20implemented,=20off=20by=20default);?= =?UTF-8?q?=20docs/persona.md=20=C2=A75.7=20reflection=20lines=20and=20?= =?UTF-8?q?=C2=A75.8=20Self-report=20and=20Mirror;=20docs/embedding.md=20s?= =?UTF-8?q?elf-report=20in=20standby=20(host=20us,=20nostd=20stack=205,552?= =?UTF-8?q?-6,568=20B,=20heap;=20silicon=20not=20measured);=20CHANGELOG;?= =?UTF-8?q?=20the=20man=20page=20regenerated=20(probbit=20reflect,=20live?= =?UTF-8?q?=20control=20reflect)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 13 ++++++ docs/embedding.md | 23 ++++++++++ docs/persona.md | 31 +++++++++++++ docs/probbit.1 | 45 ++++++++++++++++++- docs/rfcs/0013-self-report.md | 83 +++++++++++++++++++++++++++++++++++ 5 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 docs/rfcs/0013-self-report.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cb4866..77bcb9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project are documented here. The format follows ## Unreleased +- **Self-report** (RFC 0013, docs/rfcs/0013-self-report.md, docs/persona.md §5.8 "Self-report"): `probbit reflect STRAND + [--last N | --since-checkpoint] [--json | --text]` and `probbit_persona::reflect` (std and `nostd`): a canonical account of a + window of a strand's events from its lines alone, no solve: the inputs set, the expectation's surprises (z recomputed from the + logged receipts, the 5 largest |z| with predicted vs measured), per trait the levels taken and the odds' drift, the moods' + path and turning points, the drives' peaks, the habits bound, the credit settled, the learned weights' net change and the + control lines (the stance sections read from the checkpoint lines in the window; `null` without one); every number at 6 + decimals, a sha256 digest, 6-12 sentences from fixed templates. Off by default: nothing is written unless asked. +- **Reflection lines** (engine 0.14.0+, docs/persona.md §5.7): `probbit live control STRAND reflect [--last N]` appends the + reflection as a line; `live verify` recomputes it from the lines before it and refuses a forged one naming the line; an + engine before 0.14.0 reads it as unsupported (exit 5). Every strand without one, and every corpus, is byte for byte unchanged. +- **The monitor's Mirror tab**: `/reflection?last=N` and `/s/ID/reflection` serve the reflection computed from the strand file + and the latest logged reflection line with whether it recomputes equal; the page shows the sentences, the top surprises, the + habits bound and the moods at the checkpoint samples. - **The expectation's members before the turn** (docs/embedding.md "The expectation's members: the order with the reuse, and what is held"): with the cache reusing the turn's program, every member but A_t is solved before the turn (nothing of the turn held; `persona::deviation_pre`), its exact marginals held as f64 rows, and A_t's mixture runs after the turn diff --git a/docs/embedding.md b/docs/embedding.md index fe5a209..23484dd 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -408,6 +408,29 @@ the `Live` in the replay; `open` is now `open_boxed` moved out of its box, 16,73 `resume_from_record` with a record 1, 5 or 49 lines behind **16,080 B** (13,648 with the record last); `resume_boxed_from` a checkpoint + 58 lines **12,656 B** (the whole-text `resume_boxed` of the same strand 15,072 B). All three ran on a 20 KB thread. +### Self-report in standby (RFC 0013) + +A body that idles can give an account of its recent events without running a turn: `reflect::reflect_window(&lines, w)` reads +the strand's lines and returns the canonical `reflection` document (docs/rfcs/0013-self-report.md); `reflect::line(prev, &r)` +is the line to append (`{"prev", "reflection"}`), which `live verify` recomputes. No solve, no randomness, no model: one pass +over the lines from the expectation fold's start (the last checkpoint before the window carrying the expectation's memory), +each event line parsed once and each checkpoint line in the window parsed once, so the cost is O(lines read), dominated by +the checkpoint lines' size. Measured on the x86_64 host (release, LTO; median of 21 calls), with the stack high-water and the +heap peak of one call on the `nostd` build (the stack painted below the caller and scanned after the call, as for the setup +path above): + +| Strand | Window | Lines | Host µs (std) | Host µs (`nostd`) | Stack high-water | Heap peak | +| --- | --- | ---: | ---: | ---: | ---: | ---: | +| a recorded day, 1,160 events, 23 checkpoints (0.99 MB) | last 50 | 1,184 | 664 | 601 | 5,552 B | 217,674 B | +| the same | last 200 | 1,184 | 1,299 | 1,248 | 5,552 B | 223,787 B | +| the same | all | 1,184 | 5,303 | 5,283 | 5,552 B | 251,144 B | +| the tutor with an expectation block, 500 events, checkpoint every 50 | last 50 | 511 | 335 | 344 | 6,568 B | 128,292 B | +| the same | all | 511 | 1,221 | 1,320 | 6,568 B | 140,547 B | + +The heap peak is the parsed checkpoint lines (a checkpoint line of the recorded day is about 20 KB of JSON, held parsed while +it is read); a body short of heap reflects a window with fewer checkpoint lines in it. The document is byte for byte the same +from the std and `nostd` builds and the CLI (6/6 windows over the two strands). Not measured on silicon. + ### Measured on one board (ESP32-S3) First numbers from one ESP32-S3 at 240 MHz, the engine on one core shared with a 16 fps renderer, with the boxed setup path diff --git a/docs/persona.md b/docs/persona.md index 195de27..69b857f 100644 --- a/docs/persona.md +++ b/docs/persona.md @@ -1588,6 +1588,15 @@ whole text (docs/embedding.md "Resume on a body"): the header line, the last che like a segment's sealed line) and the lines after it one at a time. The record may be any state logged from that checkpoint on; an older one is refused (`state`: resume from the whole strand). +**Reflection lines (engine 0.14.0 on, RFC 0013).** `probbit live control STRAND reflect [--last N | --since-checkpoint]` +appends the strand's self-report (section 5.8, "Self-report") as one line after its last line, under the writer lock: +`{"prev": ..., "reflection": }`. A host decides when; a body calls `reflect::reflect_window` and `reflect::line` +and appends the line itself. `verify` recomputes the document from the lines before it over the window it names and refuses one +that differs (exit 1, the line named: "the reflection differs from the lines before it"); replay, the chain check and resume +pass over the line with no state change, and `live` continues a strand that ends on one (a checkpoint or a seal still follows +an event line). The writer refuses a strand of an engine before 0.14.0; an engine before 0.14.0 reads the line as +`unsupported by this engine: reflection` (exit 5). No line is written unless asked. + **Segments.** `probbit live ... --strand NAME.strand --segment-every N` seals the strand after every N-th event and goes on in a new file: the sealing line is a checkpoint line (the last line of the segment); the next file, `NAME.0001.strand`, then `NAME.0002.strand` and so on (four digits at least), starts with a header that has one more key at its end, @@ -1844,6 +1853,28 @@ canonical replay documents are unchanged. Demo bars are scripted engine state, n Exit codes: 0 when every line replays, 1 at a line that differs (the frame names it and shows the event before it), 2 for a bad flag, a port that cannot be had, or a file that cannot be read or is not a strand. +#### Self-report (RFC 0013) + +`probbit reflect STRAND [--last N | --since-checkpoint] [--json | --text]` prints a canonical account of a window of the +strand's events, computed from its lines alone: no turn is run, nothing is solved, nothing is random. Per event line it reads +what the line logs (the inputs, the expectation's receipt, the glyph): the inputs set and how often, and with an expectation +block each receipt's z, recomputed from the logged receipts (the expectation's mean and variance folded as the engine folds +them), the events that registered and the K = 5 largest |z| with what the expectation predicted (`pred`) and what the event +measured (`self`). Per checkpoint line in the window it reads the stance and state the line carries: per trait the levels taken +and the odds at the first and last sample, the moods' path (each mood's position along its levels, the monitor's sparkline +value) with its turning points, the drives' peaks, the habits that bound, the learned weights' net change. Feedback that settled +the credit, control, rest and reflection lines are counted. A window without a checkpoint line reports no levels, moods, +drives or habits: those sections are `null` (an event line logs digests, not the stance), never invented. Every number is at +6 decimals, `digest` is the sha256 of the canonical text without it, and `summary` holds 6 to 12 sentences from fixed +templates, part of the document. `--text` prints the sentences only. The library call is `probbit_persona::reflect::{reflect, +reflect_since_checkpoint, reflect_window}` (std and `nostd`). The format is docs/rfcs/0013-self-report.md; section 5.7, +"Reflection lines", appends it to the strand. + +**Mirror.** The served page has a Mirror tab: the reflection of the last 50 events computed by the server from the strand file +at each request (`/reflection?last=N`, `/s/ID/reflection` per strand), and the latest reflection line the strand carries with +whether it recomputes equal; the tab shows the sentences, the top surprises with predicted vs measured, the habits that bound +and the moods at the checkpoint samples, refreshed with the page's scan of the strand list. + ## 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 diff --git a/docs/probbit.1 b/docs/probbit.1 index 3085f0a..82492ed 100644 --- a/docs/probbit.1 +++ b/docs/probbit.1 @@ -14,6 +14,7 @@ probbit \- a virtual probabilistic (p\-bit) processor: joint decisions under har [\-\-actions FILE] [\-\-blocks FILE] \fBprobbit\fR live control STRAND pause|resume|retire|erase \-\-by WHO \-\-reason TEXT [\-\-at TIME] +\fBprobbit\fR live control STRAND reflect [\-\-last N | \-\-since\-checkpoint] \fBprobbit\fR check PERSONA [\-\-props FILE] [\-\-strand FILE]... [\-\-seeds LIST] [\-\-depth N] [\-\-hours LIST] [\-\-json] \fBprobbit\fR monitor STRAND [\-\-follow] [\-\-once] [\-\-plain] [\-\-fps N] [\-\-serve] @@ -23,6 +24,7 @@ probbit \- a virtual probabilistic (p\-bit) processor: joint decisions under har [\-\-open] [\-\-port N] \fBprobbit\fR monitor \-\-demo [week|drives] [\-\-once] [\-\-plain] [\-\-serve] [\-\-open] [\-\-port N] +\fBprobbit\fR reflect STRAND [\-\-last N | \-\-since\-checkpoint] [\-\-json | \-\-text] \fBprobbit\fR decide [flags] < router.json \fBprobbit\fR run [flags] < program.json \fBprobbit\fR evaluate [flags] < request.json @@ -301,6 +303,7 @@ a resident individual: JSONL events in, one stance per event out [\-\-actions FILE] [\-\-blocks FILE] \fBprobbit\fR live control STRAND pause|resume|retire|erase \-\-by WHO \-\-reason TEXT [\-\-at TIME] +\fBprobbit\fR live control STRAND reflect [\-\-last N | \-\-since\-checkpoint] .fi .PP A resident individual (docs/persona.md \[u00A7]5.7): JSONL events (one object of inputs per line) from \-\-events FILE or stdin, one stance per event on stdout (canonical JSON). The clock stamps each event's elapsed_hours (quantised to 1e\-6 h), so moods decay by their half\-lives between events; feedback moves the learned deltas of a persona with a learning block (\[u00A7]2.8). @@ -364,7 +367,7 @@ the host's action log, one {"turn": T, "action": {trait: level}} per line: compa write each event's block (the text a host injects, RFC 0008 E: every trait and mood as `name level`, then the viability line) to FILE, one {"block", "n"} per line; replays from the header .RE .TP -\fBcontrol\fR STRAND pause|resume|retire|erase \-\-by WHO \-\-reason TEXT [\-\-at TIME] | STRAND seal +\fBcontrol\fR STRAND pause|resume|retire|erase \-\-by WHO \-\-reason TEXT [\-\-at TIME] | STRAND seal | STRAND reflect [\-\-last N | \-\-since\-checkpoint] pause, resume or retire an individual, or erase its learned weights (RFC 0004: every learned weight back to 0, the status unchanged); appends a control line under the lock: while paused or retired every event is refused (exit 4); no credit crosses a control line; retire is final; `verify` replays them and reports the status. `seal` (section 5.7) seals the newest segment of STRAND: refused unless its last line is an event line, it appends the sealing checkpoint and writes the next segment file (its header links the sealed line, then the sealed line itself), and prints that file in `file` .RS 4 .TP @@ -376,6 +379,12 @@ why (required, except for seal) .TP \fB\-\-at\fR \fITIME\fR when (default: now, UTC) +.TP +\fB\-\-last\fR \fIN\fR +reflect: the last N events (default: every event) +.TP +\fB\-\-since\-checkpoint\fR +reflect: the events after the last checkpoint line .RE .RE .PP @@ -499,6 +508,40 @@ probbit monitor pip.strand \-\-follow probbit monitor pip.strand \-\-serve \-\-port 8080 .fi .RE +.SS reflect +RFC 0013 self\-report: a canonical account of a strand's last N events, from its lines alone +.PP +.nf +\fBprobbit\fR reflect STRAND [\-\-last N | \-\-since\-checkpoint] [\-\-json | \-\-text] +.fi +.PP +The self\-report (docs/persona.md \[u00A7]5.8): a canonical document computed from the strand's lines alone (no turn is run, nothing is solved, nothing is random): over the window, the inputs set, the expectation's surprises (each receipt's z recomputed from the logged receipts; the K = 5 largest |z| with the predicted and measured values), per trait the levels taken and the odds' drift, the moods' path with its turning points, the drives' peaks and the habits that bound (read from the checkpoint lines in the window: a window without one reports them as null), the credit settled, the learned weights' net movement and the control lines; every number at 6 decimals, `digest` = the sha256 of the canonical text, and `summary`: 6 to 12 sentences from fixed templates. `probbit live control STRAND reflect` appends it as a line (0.14.0 strands), which `live verify` recomputes. +.PP +Flags: +.RS 4 +.TP +\fB\-\-last\fR \fIN\fR +the last N events (default: every event) +.TP +\fB\-\-since\-checkpoint\fR +the events after the last checkpoint line (that checkpoint is the first sample) +.TP +\fB\-\-json\fR +the canonical document (the default) +.TP +\fB\-\-text\fR +the summary's sentences only, one per line +.RE +.PP +\fIexit\fR: 0 the reflection printed, 2 a bad flag, a file that cannot be read, or a strand with no event line in the window (one {"error"} object on stdout). +.PP +Examples: +.RS 4 +.nf +probbit reflect pip.strand \-\-last 50 \-\-text +probbit reflect pip.strand \-\-since\-checkpoint +.fi +.RE .SS decide route tasks to workers: a router document in, a JSON decision out .PP diff --git a/docs/rfcs/0013-self-report.md b/docs/rfcs/0013-self-report.md new file mode 100644 index 0000000..5fe035a --- /dev/null +++ b/docs/rfcs/0013-self-report.md @@ -0,0 +1,83 @@ +# RFC 0013: self-report, a canonical account of the last N events from the strand alone + +Status: **implemented, off by default** (0.14.0-dev: `probbit reflect`, the library's `reflect` module, the engine-gated +`reflection` line, the monitor's Mirror tab) · Changes: docs/persona.md §5.7 (one more line kind, 0.14.0+) and §5.8 +("Self-report", "Mirror"), `probbit live control`, `live verify`, `probbit monitor` · Depends on: RFC 0009 (the expectation +block and its receipts) + +Mechanism and simulation only. The self layer is a declared, exact, replayable account an individual gives of its own recent +events: arithmetic over the lines of its strand, recomputable by anyone who holds the strand. Nothing here is run, solved or +sampled; nothing is written unless a host asks. + +## What it is + +`reflect(lines, window)` reads a strand's lines and returns one canonical document, `reflection`, over a window of its event +lines: the last N (`--last N`), the events after the last checkpoint line (`--since-checkpoint`, that checkpoint the first +sample), or every event. It reads what each line logs and nothing else: + +| Section | Read from | What | +| --- | --- | --- | +| `window` | every line | first and last event n, the events, the lines read, the checkpoint samples, the strand's engine, the window asked for | +| `inputs` | event lines | per input, the events where it was true or a non-zero number (history and sensed readings are not counted) | +| `surprise` | event lines (`deviation` receipts) | per receipt z recomputed from the expectation's memory, a fold of the logged receipts (its mean and variance only, as `persona::expect_step` folds them); the events scored, the ones that registered (z ≥ 1.5), the K = 5 largest \|z\| with `pred` (what the expectation predicted), `self` (what the event measured), `input`, the glyph and the inputs set; `null` without an expectation block | +| `traits` | checkpoint lines in the window | per trait (and `pursue`) the levels taken, the odds at the first and the last sample, the smallest and largest p of the level taken | +| `moods` | checkpoint lines | per mood its position along its levels (0 the lowest, 1 the highest: the monitor's sparkline value) at each sample: start, end, min, max, the turning points (sign changes of the first difference; all counted, the first 8 listed) and the last 50 samples | +| `drives`, `habits` | checkpoint lines | per goal the largest want and its event; per habit the samples where it bound | +| `credit` | event and control lines | feedback that settled the credit (reward, correction, both on: cancelled) and the control lines that zeroed it; `null` without a learning block | +| `learned` | checkpoint lines | the learned weights' net change between the first and the last sample, and the sum of its absolute values | +| `controls` | control, rest, reflection lines, the header | pause, resume, retire, erase; `zero_credit` (every control line zeroes the credit); rest lines; earlier reflection lines; the segment index | +| `summary` | the document's numbers | 6 to 12 sentences from fixed templates, part of the canonical document | + +A section the window has no line for is `null`: an event line logs digests, not the stance, so a window without a checkpoint +line reports no levels, moods, drives or habits (absent, never invented). A strand of an older engine is read the same way; +the fields its lines lack are absent. Every number is at 6 decimals (the P0-b rule), the document is canonical JSON, and +`digest` is the sha256 of the canonical text without `digest`. + +The z of a receipt is the expectation's own: on a 0.14.0 strand the fold starts at the last checkpoint before the window that +carries the expectation's memory (its mean and variance are the fold's, at 6 decimals), else at the header; it equals the z +the checkpoint lines' stances carry (a unit test checks every checkpoint of the fixture). + +## The templates + +The sentences are the document's numbers in fixed wording, so a model can voice them or a person can read them; nothing in +them is generated. They describe mechanism. Over the expectation fixture of the test suite +(`probbit-cli/tests/fixtures/monitor/tutor-expect.strand`, 22 events, a checkpoint every 5): "This account covers events 1 to +22: 22 events and 26 lines read, 4 of them checkpoint samples.", "The expectation scored 22 events and 11 of them registered as +surprising (z at least 1.500000).", "The largest surprise was event 2: z 6.710933, the expectation predicted 2.842300 and the +event measured 2.211600.", "The mood valence moved from 0.705807 to 0.465881 on a scale of 0 to 1 (min 0.255617, max 0.705807, +2 turning points)." A unit test +holds every template's output to a list of words it never contains. + +## The reflection line (0.14.0+) + +`probbit live control STRAND reflect [--last N | --since-checkpoint]` appends the reflection as one line after the strand's +last line, under the writer lock: `{"prev": , "reflection": }`, canonical, chained like +every line. A host decides when (after a checkpoint, at the end of a day); a body in standby calls `reflect::reflect_window` +and `reflect::line` and appends the line itself. + +- `live verify` recomputes the document from the lines before it, over the window it names, and refuses one that differs (a + changed number, a changed sentence, a moved window): exit 1 naming the line, "the reflection differs from the lines before + it". Replay treats the line like a control line: no state changes, the chain goes on from it; `live` continues a strand that + ends on one. +- Engine-gated: the writer refuses a strand whose header names an engine before 0.14.0, and a 0.14.0 replay of such a strand + diverges at a reflection line. An engine before 0.14.0 reads the line as a key it does not know: `unsupported by this engine: + reflection`, exit 5 (not verified, and not a divergence). +- No line is written unless asked: every strand without reflection lines, and every corpus, is byte for byte what it was. + +## The Mirror tab + +`probbit monitor --serve` serves `/reflection?last=N` (and `/s/ID/reflection` per strand): the reflection of the last N events +(default 50) computed from the strand file as it is at the request, and the latest reflection line the strand carries with +whether it recomputes equal. The page's Mirror tab shows the sentences, the top surprises with predicted vs measured, the +habits that bound and the moods at the checkpoint samples, refreshed with the page's scan. + +## Cost + +One pass over the lines from the fold's start: each event line parsed once, each checkpoint line in the window parsed once, +no solve. Host microseconds and the stack high-water on the no_std proxy are in docs/embedding.md ("Self-report in standby"). +Silicon is not measured. + +## Not in this RFC + +Integrity (how far the individual drifted from the persona it declared), consolidation and curiosity are later RFCs of the +self layer. The reflection judges nothing: it counts and folds what the strand logs. From 536a802839ece0e11df5cc7262f02d02f62cc4e2 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 20:02:23 -0700 Subject: [PATCH 41/61] probbit-cli monitor: the Board / Mirror view buttons join the toolbar's row (the page keeps one toolbar row: the desktop view fits one viewport as before); in the Mirror view the board's toolbar controls are hidden --- probbit-cli/src/monitor.html | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/probbit-cli/src/monitor.html b/probbit-cli/src/monitor.html index c361a38..e6b62c7 100644 --- a/probbit-cli/src/monitor.html +++ b/probbit-cli/src/monitor.html @@ -25,7 +25,7 @@ .rail{flex:none;width:212px;height:100vh;position:sticky;top:0;overflow:auto;background:var(--bar);padding:16px 10px;display:flex;flex-direction:column;gap:10px}.rail h2{font-size:10.5px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;padding:0 8px;display:flex;justify-content:space-between}.rail-list{display:flex;flex-direction:column;gap:2px}.rail-item{display:flex;align-items:center;gap:10px;width:100%;text-align:left;border:0;background:none;border-radius:8px;padding:6px 8px;min-height:44px}.rail-item[aria-current=true]{background:var(--selection)}.rail-item[aria-current=true] strong{color:var(--blue)}.rail-glyph{width:22px;font-size:18px;line-height:1;text-align:center;flex:none}.rail-text{min-width:0;display:flex;flex-direction:column}.rail-text strong{font-weight:600;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.rail-text small{color:var(--muted);font-size:10.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums}.rail-keys{margin-top:auto;padding:0 8px;font-size:10px;color:var(--muted)} @media(max-width:760px){.app{flex-direction:column}.rail{width:auto;height:auto;position:static;padding:8px 10px;gap:4px}.rail-list{flex-direction:row;overflow-x:auto}.rail-item{width:auto;flex:none}.rail-keys{display:none}.who{padding:9px 12px 7px}} @media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}} -body.mirror-on .board,body.mirror-on #panel,body.mirror-on #workspace,body.mirror-on #timeline,body.mirror-on #said,body.mirror-on #diagnostics,body.mirror-on #why-panel,body.mirror-on #empty{display:none!important}.mirror{padding:14px 18px;overflow:auto}.mirror li{margin:3px 0}.mirror td,.mirror th{padding:3px 10px;text-align:left}.mirror-mood{display:flex;gap:12px;align-items:center}.mirror-mood svg{width:140px;height:25px} +body.mirror-on .toolbar>:not(.view),body.mirror-on #panel,body.mirror-on #workspace,body.mirror-on #timeline,body.mirror-on #said,body.mirror-on #diagnostics,body.mirror-on #why-panel,body.mirror-on #empty{display:none!important}.mirror{padding:14px 18px;overflow:auto}.mirror li{margin:3px 0}.mirror td,.mirror th{padding:3px 10px;text-align:left}.mirror-mood{display:flex;gap:12px;align-items:center}.mirror-mood svg{width:140px;height:25px} @@ -35,9 +35,8 @@

probbit

Activity Monitor

probbit · declared agent state
Waiting for individual
-
+
-
CONNECTINGWaiting for the local monitor.

Waiting for a strand.

From 0d1fdeb98ab7f7c448e5671386e1a8d14390d1e9 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 20:18:42 -0700 Subject: [PATCH 42/61] probbit-cli tests: the 0.13.0 fixture of the reflection test found by a joined path (the manifest path ends in \\probbit-cli on Windows) --- probbit-cli/tests/live.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/probbit-cli/tests/live.rs b/probbit-cli/tests/live.rs index 1e00d4a..259bca0 100644 --- a/probbit-cli/tests/live.rs +++ b/probbit-cli/tests/live.rs @@ -789,7 +789,7 @@ fn reflection_lines_verify_and_forgeries_are_refused() { // the life goes on after a reflection line, and the strand still verifies assert_eq!(live("{\"elapsed_hours\":1}\n").0, 0); assert_eq!(run(&["live", "verify", &f], "").0, 0); // engine-gated: a strand written by 0.13.0 takes no reflection line - let old = format!("{}/{STRANDS}/expectation-0.13.0.strand", env!("CARGO_MANIFEST_DIR").trim_end_matches("/probbit-cli")); let g = dir.join("old.strand"); std::fs::copy(&old, &g).unwrap(); + let old = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests").join("fixtures").join("strands").join("expectation-0.13.0.strand"); let g = dir.join("old.strand"); std::fs::copy(&old, &g).unwrap(); let (c, out, _) = run(&["live", "control", &g.to_string_lossy(), "reflect"], ""); assert_eq!(c, 2, "{out}"); assert!(out.contains("0.14.0"), "{out}"); assert_eq!(run(&["reflect", &g.to_string_lossy()], "").0, 0, "an older strand is read"); let _ = std::fs::remove_dir_all(&dir); From b14390cf67aca9a069d8a147f97594b62f42dbc7 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 20:38:32 -0700 Subject: [PATCH 43/61] probbit-persona: RFC 0014 integrity (0.14.0+, off by default): identity self keys keep / warn / cue / traits (outside the digest); per event the drift from the declared self (TV at the rest probe vs the seed's fresh individual), bound_used against epsilon at the seed, habits_held, the persona's own cue at warn; the event line's integrity field recomputed by verify ("the integrity differs"); the rest probe cached on the state body without turn --- probbit-persona/src/bound.rs | 1 + probbit-persona/src/integrity.rs | 160 +++++++++++++++++++++++++++++++ probbit-persona/src/lib.rs | 3 + probbit-persona/src/persona.rs | 26 +++-- probbit-persona/src/strand.rs | 21 ++-- 5 files changed, 198 insertions(+), 13 deletions(-) create mode 100644 probbit-persona/src/integrity.rs diff --git a/probbit-persona/src/bound.rs b/probbit-persona/src/bound.rs index d21b65d..7609d57 100644 --- a/probbit-persona/src/bound.rs +++ b/probbit-persona/src/bound.rs @@ -5,6 +5,7 @@ use super::*; /// The `bound` block: every field optional +#[derive(Clone)] pub struct Bound { pub rest_hours: Option, pub max_epsilon: Option, pub seeds: Option>, pub count: Vec } const SEED_MAX: u64 = 2_147_483_647; const MAX_SEEDS: usize = 10_000; diff --git a/probbit-persona/src/integrity.rs b/probbit-persona/src/integrity.rs new file mode 100644 index 0000000..1a959f5 --- /dev/null +++ b/probbit-persona/src/integrity.rs @@ -0,0 +1,160 @@ +//! RFC 0014 integrity (docs/rfcs/0014-integrity.md): per turn, how far the individual has drifted from the self its persona +//! declares, exact from the state and the persona document (mechanism: arithmetic over declared numbers; nothing here writes a +//! state). Off by default: only a persona whose `identity` block declares one of the self keys (`keep`, `warn`, `cue`, `traits`) +//! gets it, and those keys stay out of the digest (as RFC 0004's `bound`, amendment A1), so every state is as without them. +//! +//! - `drift` per trait: the total variation (half the L1 over the trait's levels) between the odds at the rest probe (one event of +//! the rest hours idle, no inputs: RFC 0004's probe) of the current individual, its learned deltas, credit, history and memory +//! included, and of the same seed's fresh individual (the declared persona); summed over the traits. +//! - `allowance` per trait: RFC 0004's epsilon at the individual's own seed when the persona declares a `bound` block and the +//! trait is learned (null otherwise); `bound_used` = drift / allowance (0 without an allowance). +//! - `used`: the summed drift over the traits with an allowance as a share of the summed allowance (0 without one): the dial. +//! - `habits_held`: of the habits `identity.keep` names, those that bound this turn (`held`) and those that did not (`not_fired`). +//! - `cue`: the persona's own `identity.cue`, when a trait's `bound_used` reaches `identity.warn` (null otherwise). +use super::*; + +/// The self keys of the `identity` block +pub const KEYS: [&str; 4] = ["keep", "warn", "cue", "traits"]; +/// The declared self: identity-bearing habits, the warn share, the cue, the traits measured, the rest probe's hours and the +/// `bound` block (when declared) that gives each learned trait its allowance +#[derive(Clone)] +pub struct Ident { pub keep: Vec, pub warn: f64, pub cue: Option, pub traits: Vec, pub rest: f64, pub bound: Option } +/// Whether an `identity` block declares a self key +pub fn declared(idn: &Json) -> bool { idn.as_obj().is_some_and(|kv| kv.iter().any(|(k, _)| KEYS.contains(&k.as_str()))) } +/// The strict reader of the self keys (`idn`: the whole `identity` block), against the persona built without them; `doc` is the +/// whole document (its `bound` block, when present, gives the allowances) +pub fn spec(idn: &Json, doc: &Json, p: &Persona) -> R { + let kv = idn.as_obj().ok_or_else(|| perr("identity", "must be a mapping"))?; + let mut keep: Vec = vec![]; + for (i, h) in list(get(kv, "keep"), "identity.keep")?.iter().enumerate() { let path = ix("identity.keep", i); + let h = h.as_str().filter(|h| p.habits.iter().any(|x| x.id == *h)).ok_or_else(|| perr(&path, "a habit id of this persona"))?; + need(!keep.iter().any(|x| x == h), &path, "listed twice")?; keep.push(h.to_string()); } + let warn = num_or(kv, "warn", 0.8, "identity", Some(0.0), Some(1.0), false)?; + need(warn > 0.0, "identity.warn", "a share of the allowance in (0, 1]")?; + let cue = match get(kv, "cue") { None | Some(Json::Null) => None, + Some(c) => Some(c.as_str().filter(|c| !c.trim().is_empty() && c.chars().count() <= 120 && !c.contains(['\n', '\r'])).ok_or_else(|| perr("identity.cue", "a short line of text (1-120 characters, one line)"))?.to_string()) }; + let mut traits: Vec = vec![]; + for (i, t) in list(get(kv, "traits"), "identity.traits")?.iter().enumerate() { let path = ix("identity.traits", i); + let t = t.as_str().filter(|t| p.var(t).is_some_and(|v| !v.mood)).ok_or_else(|| perr(&path, "a trait id of this persona"))?; + need(!traits.iter().any(|x| x == t), &path, "listed twice")?; traits.push(t.to_string()); } + if traits.is_empty() { traits = match &p.learning { Some(l) => l.traits.clone(), None => p.vars.iter().filter(|v| !v.mood).map(|v| v.id.clone()).collect() }; } + let bound = bound::of_doc(doc, p)?; + let rest = bound.as_ref().and_then(|b| b.rest_hours).unwrap_or_else(|| bound::default_rest(p)); + Ok(Ident { keep, warn, cue, traits, rest, bound }) +} + +/// One turn's integrity: the canonical `integrity` document, its summed drift and the dial's share +#[derive(Clone, Debug, PartialEq)] +pub struct Integrity { pub doc: Json, pub drift: f64, pub used: f64 } + +/// The fresh individual's odds at the rest probe and the allowances (both fixed per seed), and the last probe by state (the +/// state's body without its `turn` and digest: a turn that changes nothing else leaves the resting odds as they were) +pub struct Cache { base: Option<(u64, Vec>, Vec>)>, key: String, odds: Vec>, pub hits: u64, pub misses: u64 } +impl Cache { pub const fn new() -> Cache { Cache { base: None, key: String::new(), odds: Vec::new(), hits: 0, misses: 0 } } } +impl Default for Cache { fn default() -> Cache { Cache::new() } } + +/// The rest probe: one event of `id.rest` idle hours, no inputs -> per measured trait its odds in level order +fn probe(p: &Persona, st: &State, id: &Ident, eng: Engine) -> R>> { + let (doc, _) = turn(p, st, &Json::Obj(vec![("elapsed_hours".into(), Json::Num(id.rest))]), false, eng, false)?; + Ok(id.traits.iter().map(|t| { let o = doc.get("stance").and_then(|s| s.get(t)).and_then(|x| x.get("odds")); + p.var(t).map_or(vec![], |v| v.levels.iter().map(|lv| o.and_then(|o| o.get(lv)).and_then(Json::as_f64).unwrap_or(0.0)).collect()) }).collect()) +} +/// The cache key of a state: its body without `turn` and `digest` +fn key_of(p: &Persona, st: &State) -> String { + let mut b = st.body(p); if let Json::Obj(v) = &mut b { v.retain(|(k, _)| k != "turn" && k != "digest"); } canon(&b) +} + +/// `integrity` of the state `st` (after a turn) whose stance document is `stance` (its `habits.bound`), from a fresh cache +pub fn compute(p: &Persona, st: &State, stance: &Json, eng: Engine) -> R> { compute_cached(p, st, stance, &mut Cache::new(), eng) } +/// `compute` through a cache: one rest-probe solve when the state's body (without `turn`) changed since the last call, else none; +/// the fresh individual's probe and the allowances once per seed. None without the self keys. +pub fn compute_cached(p: &Persona, st: &State, stance: &Json, cache: &mut Cache, eng: Engine) -> R> { + let Some(id) = p.ident.as_ref() else { return Ok(None) }; + if cache.base.as_ref().map_or(true, |(s, _, _)| *s != st.seed) { + let st0 = init(p, Some(st.seed), true, eng); let p0 = probe(p, &st0, id, eng)?; + let mut eps = vec![]; + for (i, t) in id.traits.iter().enumerate() { + let k = p.learning.as_ref().and_then(|l| l.traits.iter().position(|x| x == t)); + eps.push(match (&id.bound, &p.learning, k) { + (Some(b), Some(l), Some(k)) => { let sc = bound::scope(p, b, t, id.rest, Some(&st0)); + match bound::bases(p, &sc, &st0) { Some(bs) => Some(bound::search(p, k, &sc.coords, l.total_cap, &bs, &p0[i], id.rest, eng)?.0), None => None } } + _ => None }); } + cache.base = Some((st.seed, p0, eps)); cache.key.clear(); + } + let key = key_of(p, st); + if key == cache.key { cache.hits += 1; } else { cache.odds = probe(p, st, id, eng)?; cache.key = key; cache.misses += 1; } + let (_, p0, eps) = cache.base.as_ref().unwrap(); + let (mut sum, mut sum_e, mut sum_d) = (0.0f64, 0.0f64, 0.0f64); let mut rows = vec![]; let mut crossed = vec![]; + for (i, t) in id.traits.iter().enumerate() { + let d = r6(bound::tv(&cache.odds[i], &p0[i])); sum += d; + let u = match eps[i] { Some(e) if e > 0.0 => { sum_e += e; sum_d += d; r6(d / e) } _ => 0.0 }; + if u >= id.warn { crossed.push(t.clone()); } + rows.push((t.clone(), Json::Obj(vec![("drift".into(), Json::Num(d)), ("allowance".into(), eps[i].map_or(Json::Null, Json::Num)), ("bound_used".into(), Json::Num(u))]))); } + let used = if sum_e > 0.0 { r6(sum_d / sum_e) } else { 0.0 }; + let bound_now: Vec = stance.get("habits").and_then(|h| h.get("bound")).and_then(strs).unwrap_or_default(); + let (held, not_fired): (Vec, Vec) = id.keep.iter().cloned().partition(|h| bound_now.contains(h)); + let cue = if crossed.is_empty() { Json::Null } else { id.cue.as_deref().map_or(Json::Null, |c| Json::Str(c.into())) }; + let drift = r6(sum); + let doc = Json::Obj(vec![("drift".into(), Json::Num(drift)), ("allowance".into(), Json::Num(r6(sum_e))), ("used".into(), Json::Num(used)), + ("traits".into(), Json::Obj(rows)), ("warn".into(), Json::Num(id.warn)), ("crossed".into(), jstrs(&crossed)), + ("habits_held".into(), Json::Obj(vec![("held".into(), jstrs(&held)), ("not_fired".into(), jstrs(¬_fired))])), ("cue".into(), cue)]); + Ok(Some(Integrity { doc, drift, used })) +} + +#[cfg(test)] +mod tests { + use super::*; + fn run(prog: &Json, f: &Flags) -> Json { run_program(prog, f, 1, 100, 0) } + const REF: &str = r#"{"probbit_persona":1,"identity":{"name":"I","version":"1","seed":0SELF}, + "traits":[{"id":"verbosity","levels":["terse","short","full"],"spread":0.5},{"id":"humour","levels":["none","light","playful"],"spread":0.5}], + "moods":[{"id":"valence","levels":["down","even","up"],"inertia":0.6,"half_life_hours":6}],"couplings":[{"vars":["valence","humour"],"align":0.8}], + "inputs":[{"id":"praise","kind":"flag"},{"id":"criticism","kind":"flag"},{"id":"loss","kind":"flag","effects":{"valence":-1.0}}], + "habits":[{"id":"quiet_after_loss","when":{"loss":true},"then":{"verbosity":["terse","short"]}}], + "learning":{"from":["praise","criticism"],"traits":["verbosity","humour"],"rate":0.5,"step_cap":0.2,"total_cap":1},"bound":{"seeds":"0-1","rest_hours":200}}"#; + fn persona(s: &str) -> Persona { build(&json::parse(&REF.replace("SELF", s)).unwrap()).unwrap() } + fn refused(s: &str) -> String { match build(&json::parse(&REF.replace("SELF", s)).unwrap()) { Err(e) => format!("{}: {}", e.path, e.msg), Ok(_) => "ok".into() } } + #[test] + fn self_keys_stay_out_of_the_digest_and_are_read_strictly() { + let (a, b) = (persona(""), persona(r#","keep":["quiet_after_loss"],"warn":0.5,"cue":"hold the line""#)); + assert_eq!(a.digest, b.digest); assert!(a.ident.is_none() && b.ident.is_some()); + assert_eq!(canon(&init(&a, Some(1), true, &run).to_json(&a)), canon(&init(&b, Some(1), true, &run).to_json(&b))); + assert_eq!(refused(r#","keep":["nope"]"#), "identity.keep[0]: a habit id of this persona"); + assert_eq!(refused(r#","warn":0"#), "identity.warn: a share of the allowance in (0, 1]"); + assert_eq!(refused(r#","cue":"""#), "identity.cue: a short line of text (1-120 characters, one line)"); + assert_eq!(refused(r#","traits":["valence"]"#), "identity.traits[0]: a trait id of this persona"); + assert_eq!(refused(r#","traits":["humour","humour"]"#), "identity.traits[1]: listed twice"); + } + #[test] + fn fresh_is_zero_drift_and_a_campaign_stays_under_the_allowance() { + let p = persona(r#","keep":["quiet_after_loss"],"warn":0.5,"cue":"hold the line""#); + let mut c = Cache::new(); let st = init(&p, Some(1), true, &run); + let (doc, _) = turn(&p, &st, &Json::Obj(vec![]), false, &run, false).unwrap(); + let i0 = compute_cached(&p, &st, &doc, &mut c, &run).unwrap().unwrap(); assert_eq!(i0.drift, 0.0, "{}", canon(&i0.doc)); + let mut s = st; let mut cued = false; + for n in 0..60 { let f = if n % 5 == 0 { "criticism" } else { "praise" }; + let (d, ns) = turn(&p, &s, &Json::Obj(vec![(f.into(), Json::Bool(true)), ("elapsed_hours".into(), Json::Num(0.1))]), false, &run, false).unwrap(); s = ns; + let i = compute_cached(&p, &s, &d, &mut c, &run).unwrap().unwrap(); + assert_eq!(canon(&i.doc), canon(&compute(&p, &s, &d, &run).unwrap().unwrap().doc), "the cache gives the uncached number"); + for t in ["verbosity", "humour"] { let u = i.doc.get("traits").and_then(|x| x.get(t)).and_then(|x| x.get("bound_used")).and_then(Json::as_f64).unwrap(); assert!(u <= 1.0 + 1e-5, "{t} {u}"); } + if i.doc.get("cue") == Some(&Json::Str("hold the line".into())) { cued = true; } } + assert!(cued, "the campaign crosses warn 0.5 somewhere"); + assert!(c.misses >= 2); + } + /// B3: every repo persona (examples/persona, bench/bound/fixtures) with the self keys declared: a fresh individual drifts 0 + #[cfg(feature = "std")] + #[test] + fn fresh_individual_has_zero_drift_on_every_repo_persona() { + let root = concat!(env!("CARGO_MANIFEST_DIR"), "/.."); + let mut n = 0; + for dir in ["examples/persona", "bench/bound/fixtures"] { + let mut fs: Vec<_> = std::fs::read_dir(format!("{root}/{dir}")).unwrap().filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.extension().is_some_and(|x| x == "yaml")).collect(); fs.sort(); + for f in fs { let mut d = crate::yaml::load(&std::fs::read_to_string(&f).unwrap()).unwrap_or_else(|e| panic!("{}: {}", f.display(), e.msg)); if d.get("probbit_persona").is_none() { continue; } + if let Json::Obj(v) = &mut d { if let Some((_, Json::Obj(i))) = v.iter_mut().find(|(k, _)| k == "identity") { i.push(("warn".into(), Json::Num(0.8))); } } + let p = build(&d).unwrap_or_else(|e| panic!("{}: {}: {}", f.display(), e.path, e.msg)); + for seed in [0u64, 7] { let st = init(&p, Some(seed), true, &run); + let i = compute(&p, &st, &Json::Null, &run).unwrap().unwrap(); + eprintln!("B3 fresh\t{dir}/{}\tseed {seed}\tdrift {}\tused {}\tallowance {}", f.file_name().unwrap().to_string_lossy(), i.drift, i.used, canon(i.doc.get("allowance").unwrap())); + assert_eq!(i.drift, 0.0, "{}", f.display()); n += 1; } } } + assert!(n >= 20, "{n}"); + } +} diff --git a/probbit-persona/src/lib.rs b/probbit-persona/src/lib.rs index a6ce1a7..313703c 100644 --- a/probbit-persona/src/lib.rs +++ b/probbit-persona/src/lib.rs @@ -6,6 +6,7 @@ //! `evaluate` (feature `decide`): the decision-API adapter. //! - `strand`: the strand writer (`Live`: start, event, control, checkpoint and rest lines, the `prev` chain) and its checks over //! a `&str` (`Replay`, `verify*`, `read_actions`, `first_difference`), with no file I/O. +//! - `integrity`: RFC 0014, the drift from the declared self per turn (off without the `identity` self keys). //! - `reflect`: RFC 0013, self-report: the canonical account of a window of a strand's events, from its lines alone (no solve). //! - `trace`: with feature `turn_trace`, one event's time and a host probe's bytes by phase (`Live::event`). //! - `sys`: the process's resource usage (getrusage on 64-bit Unix; `None` elsewhere). `hooks`: the host's monitor and config echo. @@ -30,6 +31,8 @@ pub mod evaluate; pub mod hooks; pub mod json; pub mod persona; +/// RFC 0014: `integrity::{Integrity, compute}` (the module lives under `persona`, beside RFC 0004's `bound`) +pub use persona::integrity; pub mod reflect; #[cfg(feature = "decide")] pub mod router; diff --git a/probbit-persona/src/persona.rs b/probbit-persona/src/persona.rs index aeac901..3690cdd 100644 --- a/probbit-persona/src/persona.rs +++ b/probbit-persona/src/persona.rs @@ -20,6 +20,9 @@ pub mod bound; /// The dead-rule lint: habits a host can never bring into force (`persona lint --host`) #[path = "hostlint.rs"] pub mod hostlint; +/// RFC 0014 integrity: the drift from the self the persona declares (off without the `identity` self keys) +#[path = "integrity.rs"] +pub mod integrity; 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"]; @@ -207,7 +210,9 @@ pub struct Persona { pub name: String, pub version: String, pub seed: u64, pub d /// RFC 0009: the `expectation` block and `engine.trajectory` (both outside the digest; effects {}) pub expect: Option, pub traj: Option, /// §2.18: the `display.glyphs` block (outside the digest; effects {}) - pub glyphs: Option } + pub glyphs: Option, + /// RFC 0014: the `identity` block's self keys (outside the digest; effects {}) + pub ident: Option } impl Persona { /// `line.why_past` (§2.11): `why` names what the learned weights came from; default on with a senses block, off without fn why_past(&self) -> bool { self.why_past.unwrap_or(self.senses.is_some()) } @@ -343,6 +348,12 @@ pub fn build(doc: &Json) -> R { build_boxed(doc).map(|p| *p) } /// recursion through the layers holds only this dispatcher's frame (docs/embedding.md "Stack budget"). #[inline(never)] pub fn build_boxed(doc: &Json) -> R> { + // RFC 0014: the `identity` block's self keys move nothing (effects {}): they are read against the persona built without them + // and stay out of the digest, so declaring them on a persona with a recorded life leaves every state as it was + if let Some(idn) = doc.get("identity").filter(|i| integrity::declared(i)) { + let mut d = doc.clone(); + if let Json::Obj(v) = &mut d { if let Some((_, Json::Obj(i))) = v.iter_mut().find(|(k, _)| k == "identity") { i.retain(|(k, _)| !integrity::KEYS.contains(&k.as_str())); } } + let mut p = build_boxed(&d)?; let id = integrity::spec(idn, doc, &p)?; p.ident = Some(id); return Ok(p); } // §2.18: `display` moves nothing (effects {}): it is read against the persona built without it and stays out of the digest, // so adding it to a persona with a recorded life leaves every state and stance digest as it was if let Some(dj) = doc.get("display") { let mut d = doc.clone(); if let Json::Obj(v) = &mut d { v.retain(|(k, _)| k != "display"); } @@ -502,7 +513,7 @@ fn build_base(doc: &Json) -> R> { 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 learning = match get(kv, "learning").filter(|l| !l.is_null()) { None => None, Some(l) => Some(learning_spec(l, &vars, &inputs)?) }; let reward = match get(kv, "reward_from").filter(|x| !x.is_null()) { None => None, Some(rf) => Some(reward_spec(rf, &learning.as_ref().map_or(vec![], |l| l.from.clone()), &inputs)?) }; - let p = Box::new(Persona { name, version, seed, digest, vars, steps, slots, say_order, prefer, couplings, inputs, history, habits, eng, max_tokens, order, prefix, learning, drives: None, reward, senses: None, why_past, surprise: None, pace: None, disp: None, rest_b: None, load: None, block, expect: None, traj: None, glyphs: None }); + let p = Box::new(Persona { name, version, seed, digest, vars, steps, slots, say_order, prefer, couplings, inputs, history, habits, eng, max_tokens, order, prefix, learning, drives: None, reward, senses: None, why_past, surprise: None, pace: None, disp: None, rest_b: None, load: None, block, expect: None, traj: None, glyphs: None, ident: None }); // 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()); } @@ -1547,10 +1558,11 @@ struct Pay { wait: bool, pays: Option<(u64, Option)>, cancel: Option bool { s.len() == 71 && s.starts_with("sha256:") && s[7..].bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) } -/// A stance's digest (§2.8): sha256 of the stance document's canonical JSON without `credit`, `state_digest` and `timing`. The +/// A stance's digest (§2.8): sha256 of the stance document's canonical JSON without `credit`, `state_digest`, `timing` and +/// `integrity` (RFC 0014, computed after the turn). The /// state remembers it (an open decision's, the previous stance's) without hashing itself, and `for: {turn, stance}` binds it. pub fn stance_digest(doc: &Json) -> String { - match doc { Json::Obj(v) => sha(&Json::Obj(v.iter().filter(|(k, _)| !["credit", "state_digest", "timing"].contains(&k.as_str())).cloned().collect())), _ => sha(doc) } + match doc { Json::Obj(v) => sha(&Json::Obj(v.iter().filter(|(k, _)| !["credit", "state_digest", "timing", "integrity"].contains(&k.as_str())).cloned().collect())), _ => sha(doc) } } /// With a credit block: `await`, `for` and `cancel` taken out of the inputs and read (B-R5: a malformed value refuses the event); /// without one they stay undeclared inputs (listed in `ignored`) @@ -4592,10 +4604,10 @@ pub fn trajectory_program(prog: &Json, k: usize, beta0: f64, limit: u64) -> Opti pub fn trajectory(p: &Persona, st: &State, raw: &Json) -> Option { let t = p.traj.as_ref()?; trajectory_program(&program_json(p, st, raw).ok()?, t.k, t.beta0, p.eng.exact_limit) } -/// A stance document's sha256 as strand lines log it: without `deviation` (RFC 0009) and without `glyph` and `glyph_why` (§2.18); -/// both have effects {}: the blocks on or off, one digest +/// A stance document's sha256 as strand lines log it: without `deviation` (RFC 0009), without `glyph` and `glyph_why` (§2.18) and +/// without `integrity` (RFC 0014); all have effects {}: the blocks on or off, one digest pub fn stance_sha(doc: &Json) -> String { - const OUT: [&str; 3] = ["deviation", "glyph", "glyph_why"]; + const OUT: [&str; 4] = ["deviation", "glyph", "glyph_why", "integrity"]; match doc { Json::Obj(v) if v.iter().any(|(k, _)| OUT.contains(&k.as_str())) => sha(&Json::Obj(v.iter().filter(|(k, _)| !OUT.contains(&k.as_str())).cloned().collect())), _ => sha(doc) } } diff --git a/probbit-persona/src/strand.rs b/probbit-persona/src/strand.rs index 6965794..80758d1 100644 --- a/probbit-persona/src/strand.rs +++ b/probbit-persona/src/strand.rs @@ -56,7 +56,9 @@ pub struct Live { pub p: Persona, pub st: State, clock: Clock, last: f64, prev: /// The strand's engine is before 0.14.0 (DEV_R6_SINCE): the stance's `deviation` keeps full precision, as that engine wrote it pub dev_full: bool, /// RFC 0009: the members' exact marginals by program (`persona::ExpectCache`, on by default; off gives the same bytes) - pub expect_cache: persona::ExpectCache } + pub expect_cache: persona::ExpectCache, + /// RFC 0014: the rest probes of `integrity` (the fresh individual's once per seed; the individual's when its state moved) + pub integ: persona::integrity::Cache } /// The engine from which the stance's `deviation` (on checkpoint lines) is rounded to 6 decimals: a transcendental's last bits /// differ between platforms' libm (macOS, glibc, the `libm` crate of a `nostd` build), and 6 decimals make the line's bytes, /// and so the chain, the same on all of them @@ -115,7 +117,7 @@ impl Live { fn boxed(p: Box, st: Box, clock: Clock, prev: String, n: u64, expect: Option, dev_full: bool) -> Box { use core::mem::MaybeUninit; use core::ptr::{addr_of_mut, copy_nonoverlapping}; // every field is written below: this pattern stops compiling when a field is added and not written - let _all = |l: &Live| { let Live { p: _, st: _, clock: _, last: _, prev: _, n: _, status: _, controls: _, checkpoints: _, rests: _, expect: _, traj: _, dev_full: _, expect_cache: _ } = l; }; + let _all = |l: &Live| { let Live { p: _, st: _, clock: _, last: _, prev: _, n: _, status: _, controls: _, checkpoints: _, rests: _, expect: _, traj: _, dev_full: _, expect_cache: _, integ: _ } = l; }; let mut b: Box> = Box::new(MaybeUninit::uninit()); let l = b.as_mut_ptr(); // SAFETY: `l` points to an allocation for one `Live`; each field is written once, through raw field pointers (no reference @@ -129,6 +131,7 @@ impl Live { addr_of_mut!((*l).status).write(Status::Active); addr_of_mut!((*l).controls).write(0); addr_of_mut!((*l).checkpoints).write(0); addr_of_mut!((*l).rests).write(0); addr_of_mut!((*l).expect).write(expect); addr_of_mut!((*l).traj).write(None); addr_of_mut!((*l).dev_full).write(dev_full); addr_of_mut!((*l).expect_cache).write(persona::ExpectCache::new(true)); + addr_of_mut!((*l).integ).write(persona::integrity::Cache::new()); Box::from_raw(Box::into_raw(b) as *mut Live) } } @@ -167,6 +170,9 @@ impl Live { if self.expect.is_some() { if let Json::Obj(v) = &mut stance { v.push(("deviation".into(), dev.as_ref().map_or(Json::Null, |d| d.to_json(self.dev_full)))); } } // §2.18: the glyph reads the finished document, `deviation` included (the surprise modifier) persona::set_glyph(&self.p, &mut stance); + // RFC 0014 (0.14.0+): the drift from the declared self, from the state after the turn (effects {}: outside both digests) + let integ = if self.dev_full { None } else { persona::integrity::compute_cached(&self.p, &ns, &stance, &mut self.integ, eng)? }; + if let (Some(ig), Json::Obj(v)) = (&integ, &mut stance) { v.push(("integrity".into(), ig.doc.clone())); } // RFC 0002 part 2: a rest line due before this event is its own line, chained before the event line (the turn applied it) enter(Phase::Line); let (rest, prev) = match persona::rest_line_digest(&self.p, &self.st, &inputs) { @@ -176,6 +182,7 @@ impl Live { ("state".into(), Json::Str(ns.digest.clone())), ("prev".into(), Json::Str(prev))]; if let Some(d) = &dev { lj.push(("deviation".into(), d.receipt())); } if let Some(g) = stance.get("glyph") { lj.push(("glyph".into(), g.clone())); } + if let Some(ig) = &integ { lj.push(("integrity".into(), ig.doc.clone())); } let line = persona::canon(&Json::Obj(lj)); enter(Phase::Fold); if let Some(m) = self.expect.as_mut() { persona::expect_step(&self.p, m, &inputs, dev.as_ref().and_then(persona::Deviation::logged_pred)); } @@ -300,7 +307,7 @@ pub fn segment_header(lv: &Live, doc: &Json, engine: &str, index: u64, previous: /// this engine does not know (a line without `control` or `checkpoint` is read as an event line, so `{"prev": ..., "nap": ...}` /// gives `nap`) or a field it does not know on a known one fn unknown_key(j: &Json) -> Option<&str> { - let known: &[&str] = match kind(j) { Kind::Event => &["deviation", "glyph", "inputs", "n", "prev", "stance", "state"], Kind::Control => &["at", "by", "control", "prev", "reason"], + let known: &[&str] = match kind(j) { Kind::Event => &["deviation", "glyph", "inputs", "integrity", "n", "prev", "stance", "state"], Kind::Control => &["at", "by", "control", "prev", "reason"], Kind::Checkpoint => &["checkpoint", "expect", "prev", "stance", "state"], Kind::Rest => &["prev", "rest", "state"], Kind::Reflection => &["prev", "reflection"] }; j.as_obj()?.iter().map(|(k, _)| k.as_str()).find(|k| !known.contains(k)) } @@ -471,6 +478,8 @@ impl Replay { if json::parse(&line).ok().and_then(|x| x.get("deviation").cloned()) != j.get("deviation").cloned() { return Err((i, "the deviation differs".into())); } // §2.18: the logged glyph is recomputed from the replayed stance (an edited glyph diverges here) if stance.get("glyph") != j.get("glyph") { return Err((i, "the glyph differs".into())); } + // RFC 0014: the logged integrity is recomputed from the replayed state (an edited number diverges here) + if stance.get("integrity") != j.get("integrity") { return Err((i, "the integrity differs".into())); } if j.get("state").and_then(Json::as_str) != Some(live.st.digest.as_str()) { return Err((i, "the state differs".into())); } if line != l { return Err((i, "the line differs".into())); } self.line = i; self.last = Some(stance.clone()); self.checkpoint_ready = true; self.norms.track(&self.live.p, &self.live.st); @@ -697,9 +706,9 @@ fn check_checkpoint(p: &Persona, canon: bool, before: Option<&str>, cp: &str, at return Err((at, if with { "checkpoint fields must be checkpoint, expect, prev, stance and state" } else { "checkpoint fields must be checkpoint, prev, stance and state" }.into())); } // §2.18: an event line of a persona with glyphs also carries `glyph`; RFC 0009: of a persona with an expectation block, `deviation` - let extra = |kv: &[(String, Json)]| kv.iter().filter(|(k, _)| k == "glyph" || k == "deviation").count(); + let extra = |kv: &[(String, Json)]| kv.iter().filter(|(k, _)| k == "glyph" || k == "deviation" || k == "integrity").count(); if let Some(before) = before { - if !b.as_obj().is_some_and(|kv| kv.len() == 5 + extra(kv) && kv.iter().all(|(k, _)| ["n", "inputs", "prev", "stance", "state", "glyph", "deviation"].contains(&k.as_str()))) { + if !b.as_obj().is_some_and(|kv| kv.len() == 5 + extra(kv) && kv.iter().all(|(k, _)| ["n", "inputs", "prev", "stance", "state", "glyph", "deviation", "integrity"].contains(&k.as_str()))) { return Err((at - 1, "a checkpoint must immediately follow an event line".into())); } if j.get("prev").and_then(Json::as_str) != Some(persona::digest_of(before).as_str()) { return Err((at, "prev is not the sha256 of the line before".into())); } @@ -980,7 +989,7 @@ fn line_check(j: &Json, l: &str, inputs: Json, stance: &Json, state: &str) -> Re if j.get("state").and_then(Json::as_str) != Some(state) { return Err("the state differs".into()); } let mut lj = vec![("n".into(), j.get("n").cloned().unwrap_or(Json::Null)), ("inputs".into(), inputs), ("stance".into(), Json::Str(sd)), ("state".into(), Json::Str(state.to_string())), ("prev".into(), j.get("prev").cloned().unwrap_or(Json::Null))]; - for k in ["deviation", "glyph"] { if let Some(x) = j.get(k) { lj.push((k.into(), x.clone())); } } + for k in ["deviation", "glyph", "integrity"] { if let Some(x) = j.get(k) { lj.push((k.into(), x.clone())); } } if persona::canon(&Json::Obj(lj)) != l { return Err("the line differs".into()); } Ok(()) } From a17f270de5dc119b4596f77d57c162e241bddbe1 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 20:52:25 -0700 Subject: [PATCH 44/61] probbit-persona integrity: the rest probe runs on the individual rested (the channels of the hour, moods with a half-life, pace, load, credit awaiting feedback, the last stance, the agenda's step, at the fresh values), so the cache on the rested body hits on every event that moves only the hour; the document in canonical key order (the stance's copy equals the line's: verify recomputed it unequal before); the cue (identity.cue past warn) is the integrity document's field --- probbit-persona/src/integrity.rs | 42 +++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/probbit-persona/src/integrity.rs b/probbit-persona/src/integrity.rs index 1a959f5..896e483 100644 --- a/probbit-persona/src/integrity.rs +++ b/probbit-persona/src/integrity.rs @@ -4,8 +4,12 @@ //! gets it, and those keys stay out of the digest (as RFC 0004's `bound`, amendment A1), so every state is as without them. //! //! - `drift` per trait: the total variation (half the L1 over the trait's levels) between the odds at the rest probe (one event of -//! the rest hours idle, no inputs: RFC 0004's probe) of the current individual, its learned deltas, credit, history and memory -//! included, and of the same seed's fresh individual (the declared persona); summed over the traits. +//! the rest hours idle, no inputs: RFC 0004's probe) of the current individual rested, and of the same seed's fresh individual +//! (the declared persona); summed over the traits. Rested (`rested`): the channels of the hour (moods with `half_life_hours`, +//! the pace average, the load, the credit awaiting feedback, the last levels and stance, the agenda's step) at the fresh +//! individual's values, everything that persists as it is (learned deltas, history, counted moods, drives, the senses' normals, +//! the gates): the drift +//! measures what stays, not the mood of the hour, and the rest probe's cache hits whenever only the hour moved. //! - `allowance` per trait: RFC 0004's epsilon at the individual's own seed when the persona declares a `bound` block and the //! trait is learned (null otherwise); `bound_used` = drift / allowance (0 without an allowance). //! - `used`: the summed drift over the traits with an allowance as a share of the summed allowance (0 without one): the dial. @@ -49,7 +53,7 @@ pub struct Integrity { pub doc: Json, pub drift: f64, pub used: f64 } /// The fresh individual's odds at the rest probe and the allowances (both fixed per seed), and the last probe by state (the /// state's body without its `turn` and digest: a turn that changes nothing else leaves the resting odds as they were) -pub struct Cache { base: Option<(u64, Vec>, Vec>)>, key: String, odds: Vec>, pub hits: u64, pub misses: u64 } +pub struct Cache { base: Option<(u64, Vec>, Vec>, State)>, key: String, odds: Vec>, pub hits: u64, pub misses: u64 } impl Cache { pub const fn new() -> Cache { Cache { base: None, key: String::new(), odds: Vec::new(), hits: 0, misses: 0 } } } impl Default for Cache { fn default() -> Cache { Cache::new() } } @@ -59,10 +63,22 @@ fn probe(p: &Persona, st: &State, id: &Ident, eng: Engine) -> R>> { Ok(id.traits.iter().map(|t| { let o = doc.get("stance").and_then(|s| s.get(t)).and_then(|x| x.get("odds")); p.var(t).map_or(vec![], |v| v.levels.iter().map(|lv| o.and_then(|o| o.get(lv)).and_then(Json::as_f64).unwrap_or(0.0)).collect()) }).collect()) } -/// The cache key of a state: its body without `turn` and `digest` +/// The cache key of a rested state: its body without `turn` and `digest` (a rested state has no open decision, whose credit +/// would age in turns) fn key_of(p: &Persona, st: &State) -> String { let mut b = st.body(p); if let Json::Obj(v) = &mut b { v.retain(|(k, _)| k != "turn" && k != "digest"); } canon(&b) } +/// The individual rested: the channels of the hour at the fresh individual's values `st0` (moods with a half-life, the pace +/// average, the load, the rest pressure, the eligibility trace and the credit awaiting feedback with its open decisions, the +/// levels and stance taken last, the agenda's step); what persists as it is (learned deltas, history, moods without a +/// half-life, drives, senses, the surprise gates, genes) +pub fn rested(p: &Persona, st: &State, st0: &State) -> State { + let mut s = st.clone(); + for (id, acc) in s.mood.iter_mut() { if p.var(id).is_some_and(|v| v.half_life.is_some()) { if let Some((_, a0)) = st0.mood.iter().find(|(k, _)| k == id) { acc.clone_from(a0); } } } + s.rbar = st0.rbar; s.load_l = st0.load_l; s.pressure = st0.pressure; s.elig = st0.elig; + s.prev.clone_from(&st0.prev); s.prev_stance.clone_from(&st0.prev_stance); s.agenda.clone_from(&st0.agenda); + s.credit.clone_from(&st0.credit); s.open.clone_from(&st0.open); s.seal(p); s +} /// `integrity` of the state `st` (after a turn) whose stance document is `stance` (its `habits.bound`), from a fresh cache pub fn compute(p: &Persona, st: &State, stance: &Json, eng: Engine) -> R> { compute_cached(p, st, stance, &mut Cache::new(), eng) } @@ -70,7 +86,7 @@ pub fn compute(p: &Persona, st: &State, stance: &Json, eng: Engine) -> R R> { let Some(id) = p.ident.as_ref() else { return Ok(None) }; - if cache.base.as_ref().map_or(true, |(s, _, _)| *s != st.seed) { + if cache.base.as_ref().map_or(true, |(s, _, _, _)| *s != st.seed) { let st0 = init(p, Some(st.seed), true, eng); let p0 = probe(p, &st0, id, eng)?; let mut eps = vec![]; for (i, t) in id.traits.iter().enumerate() { @@ -79,11 +95,12 @@ pub fn compute_cached(p: &Persona, st: &State, stance: &Json, cache: &mut Cache, (Some(b), Some(l), Some(k)) => { let sc = bound::scope(p, b, t, id.rest, Some(&st0)); match bound::bases(p, &sc, &st0) { Some(bs) => Some(bound::search(p, k, &sc.coords, l.total_cap, &bs, &p0[i], id.rest, eng)?.0), None => None } } _ => None }); } - cache.base = Some((st.seed, p0, eps)); cache.key.clear(); + cache.base = Some((st.seed, p0, eps, st0)); cache.key.clear(); } - let key = key_of(p, st); - if key == cache.key { cache.hits += 1; } else { cache.odds = probe(p, st, id, eng)?; cache.key = key; cache.misses += 1; } - let (_, p0, eps) = cache.base.as_ref().unwrap(); + let rs = rested(p, st, &cache.base.as_ref().unwrap().3); + let key = key_of(p, &rs); + if key == cache.key { cache.hits += 1; } else { cache.odds = probe(p, &rs, id, eng)?; cache.key = key; cache.misses += 1; } + let (_, p0, eps, _) = cache.base.as_ref().unwrap(); let (mut sum, mut sum_e, mut sum_d) = (0.0f64, 0.0f64, 0.0f64); let mut rows = vec![]; let mut crossed = vec![]; for (i, t) in id.traits.iter().enumerate() { let d = r6(bound::tv(&cache.odds[i], &p0[i])); sum += d; @@ -98,6 +115,8 @@ pub fn compute_cached(p: &Persona, st: &State, stance: &Json, cache: &mut Cache, let doc = Json::Obj(vec![("drift".into(), Json::Num(drift)), ("allowance".into(), Json::Num(r6(sum_e))), ("used".into(), Json::Num(used)), ("traits".into(), Json::Obj(rows)), ("warn".into(), Json::Num(id.warn)), ("crossed".into(), jstrs(&crossed)), ("habits_held".into(), Json::Obj(vec![("held".into(), jstrs(&held)), ("not_fired".into(), jstrs(¬_fired))])), ("cue".into(), cue)]); + // canonical key order (the order the strand line is written in), so the stance's copy equals the line's + let doc = json::parse(&canon(&doc)).map_err(|e| perr("integrity", e.msg))?; Ok(Some(Integrity { doc, drift, used })) } @@ -139,6 +158,11 @@ mod tests { if i.doc.get("cue") == Some(&Json::Str("hold the line".into())) { cued = true; } } assert!(cued, "the campaign crosses warn 0.5 somewhere"); assert!(c.misses >= 2); + // moods move every loss event; the rested state does not see them: a run of losses after the campaign hits the cache + let before = (c.hits, c.misses); + for _ in 0..5 { let (d, ns) = turn(&p, &s, &Json::Obj(vec![("loss".into(), Json::Bool(true)), ("elapsed_hours".into(), Json::Num(0.1))]), false, &run, false).unwrap(); s = ns; + compute_cached(&p, &s, &d, &mut c, &run).unwrap().unwrap(); } + assert_eq!((c.hits - before.0, c.misses - before.1), (5, 0), "five mood-only events: five hits"); } /// B3: every repo persona (examples/persona, bench/bound/fixtures) with the self keys declared: a fresh individual drifts 0 #[cfg(feature = "std")] From 9c6820797b38309811723ee68b2788bc70087473 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 20:53:51 -0700 Subject: [PATCH 45/61] probbit-cli monitor: the integrity dial (RFC 0014): with the stance's integrity the instrument panel's room placeholder becomes the dial (used = the summed drift over the summed allowance; fixed words within warn / past warn on N of M); the Mirror tab's per-trait drift table of the event on view; serve-path test the_integrity_dial_reads_the_strands_number (the frame's document carries the last line's integrity, the dial is its used) on a repo fixture (tutor-self.yaml) --- probbit-cli/src/monitor.html | 9 +- probbit-cli/src/monitor.rs | 7 +- probbit-cli/src/monitor/panel.rs | 18 +- .../tests/fixtures/monitor/tutor-self.yaml | 190 ++++++++++++++++++ probbit-cli/tests/monitor.rs | 29 +++ 5 files changed, 247 insertions(+), 6 deletions(-) create mode 100644 probbit-cli/tests/fixtures/monitor/tutor-self.yaml diff --git a/probbit-cli/src/monitor.html b/probbit-cli/src/monitor.html index e6b62c7..9d4f616 100644 --- a/probbit-cli/src/monitor.html +++ b/probbit-cli/src/monitor.html @@ -36,7 +36,7 @@

Activity Monitor

probbit · declared agent state
Waiting for individual
- +
CONNECTINGWaiting for the local monitor.

Waiting for a strand.

@@ -426,7 +426,12 @@ const s=$('mirror-summary');s.replaceChildren();(d.summary||[]).forEach(x=>mk('li','',s,x)); const t=$('mirror-top');t.replaceChildren();(get(d,'surprise','top')||[]).forEach(e=>{const r=mk('tr','',t);[e.n,f6(e.z),f6(e.pred),f6(e.self),(e.set||[]).join(', ')||'—'].forEach(v=>mk('td','number',r,String(v)));}); const h=$('mirror-habits');h.replaceChildren();Object.entries(obj(get(d,'habits','bound'))||{}).forEach(([k,c])=>mk('span','chip',h,k+' ×'+c)); - const m=$('mirror-moods');m.replaceChildren();Object.entries(obj(d.moods)||{}).forEach(([k,v])=>{const row=mk('div','mirror-mood',m);mk('span','',row,k+' '+f2(v.start)+' → '+f2(v.end)+' · '+v.turns+' turning points');plot(svg('svg',{viewBox:'0 0 140 25',role:'img','aria-label':k+' by checkpoint sample'},row),(v.path||[]).map(([n,value])=>({n,value})),{width:140,height:25});});} + const m=$('mirror-moods');m.replaceChildren();Object.entries(obj(d.moods)||{}).forEach(([k,v])=>{const row=mk('div','mirror-mood',m);mk('span','',row,k+' '+f2(v.start)+' → '+f2(v.end)+' · '+v.turns+' turning points');plot(svg('svg',{viewBox:'0 0 140 25',role:'img','aria-label':k+' by checkpoint sample'},row),(v.path||[]).map(([n,value])=>({n,value})),{width:140,height:25});}); + integrityTable();} +// RFC 0014: the per-trait drift of the event on view (its stance's `integrity`, recomputed by verify from the strand) +function integrityTable(){const ig=obj(get(viewed,'doc','integrity')),box=$('mirror-integrity');box.hidden=!ig;if(!ig)return; + $('mirror-integrity-note').textContent='Event '+viewed.n+' · used '+f6(ig.used)+' of the allowance · drift '+f6(ig.drift)+' · warn at '+f2(ig.warn)+(Array.isArray(ig.crossed)&&ig.crossed.length?' · past warn: '+ig.crossed.join(', '):''); + const t=$('mirror-integrity-rows');t.replaceChildren();Object.entries(obj(ig.traits)||{}).forEach(([k,v])=>{const r=mk('tr','',t);mk('td','',r,k);[v.drift,v.allowance,v.bound_used].forEach(x=>mk('td','number',r,f6(x)));});} const f6=x=>num(x)?x.toFixed(6):'—'; setInterval(mirror,2000); const safe=fn=>e=>{try{fn(JSON.parse(e.data));}catch(x){console.error(x);}}; diff --git a/probbit-cli/src/monitor.rs b/probbit-cli/src/monitor.rs index a65621c..be690c4 100644 --- a/probbit-cli/src/monitor.rs +++ b/probbit-cli/src/monitor.rs @@ -244,7 +244,12 @@ fn readings(m: &Meta, doc: &Json, learned: &Json, disp: Option<&Json>, open: Opt out.push(r("habits".into(), "habits", Field::Habits { active: n("active"), bound: n("bound"), declared: m.n_habits })); for x in &m.senses { let z = doc.get("senses").and_then(|s| s.get(&x.id)).and_then(|s| s.get("z")).and_then(Json::as_f64); out.push(r(format!("sense:{}", x.id), &format!("sense {}", x.id), Field::Sense { z })); } - out.push(r("room".into(), "room left in the bound", Field::Room)); + // RFC 0014: with the identity self keys the stance carries `integrity`: its dial replaces the room placeholder + match doc.get("integrity").filter(|x| x.as_obj().is_some()) { + Some(ig) => { let g = |k: &str| ig.get(k).and_then(Json::as_f64).unwrap_or(0.0); + out.push(r("integrity".into(), "integrity (drift from the declared self)", Field::Integrity { used: g("used"), drift: g("drift"), + crossed: ig.get("crossed").and_then(Json::as_arr).map_or(0, <[Json]>::len), traits: ig.get("traits").and_then(Json::as_obj).map_or(0, <[(String, Json)]>::len) })); } + None => out.push(r("room".into(), "room left in the bound", Field::Room)) } out } diff --git a/probbit-cli/src/monitor/panel.rs b/probbit-cli/src/monitor/panel.rs index bac6f51..8633691 100644 --- a/probbit-cli/src/monitor/panel.rs +++ b/probbit-cli/src/monitor/panel.rs @@ -49,6 +49,9 @@ pub enum Field { Undeclared, /// Room left in the learning bound (RFC 0004): not computed until the engine has it Room, + /// RFC 0014 integrity: the summed drift as a share of the summed allowance (`used`), the summed drift, the traits past + /// `identity.warn` and the traits measured + Integrity { used: f64, drift: f64, crossed: usize, traits: usize }, } /// A field at an event: its stable key (`stance:verbosity`, `mood:pace`, `pace`, …), its label and its reading #[derive(Clone, Debug, PartialEq)] @@ -85,6 +88,7 @@ fn gene_level(gene: &str, x: f64) -> &'static str { ("patience", true) => "longer credit memory", ("patience", false) => "shorter credit memory", (_, true) => "surprise gate opens later", (_, false) => "surprise gate opens sooner" } } +fn integrity_level(crossed: usize, traits: usize) -> String { if crossed == 0 { "within warn".into() } else { format!("past warn on {crossed} of {traits}") } } fn count_level(n: usize) -> String { if n == 0 { "none open".into() } else { format!("{n} open") } } fn learned_level(levels: &[String], ds: &[f64]) -> String { if ds.iter().all(|d| d.abs() < LEARN_STEP) { return "none yet".into(); } @@ -157,6 +161,11 @@ pub fn word(now: &Reading, prev: Option<&Reading>) -> Word { }, Field::Undeclared => (UNDECLARED.to_string(), "", String::new(), None, String::new()), Field::Room => (ROOM.to_string(), "", String::new(), None, String::new()), + Field::Integrity { used, drift, crossed, traits } => { + let m = used.clamp(0.0, 1.0); + let d = match before { Some(Field::Integrity { used: x, .. }) => trend(used - x, NUM_STEP).to_string(), _ => FIRST.to_string() }; + (integrity_level(*crossed, *traits), if *used <= 0.0 { "" } else { size_of(m) }, d, Some(m), format!("used {used:.6} drift {drift:.6}")) + } }; let text = line(&now.label, &level, size, &dir); Word { key: now.key.clone(), label: now.label.clone(), level, size, dir, text, dial, value } @@ -186,6 +195,7 @@ pub fn space(field: &Field) -> (Vec, Vec<&'static str>, Vec) { Field::Sense { .. } => (vec!["read".into(), "not read".into()], { let mut s = s3(); s.push(""); s }, vec![String::new(), "above its normal".into(), "below its normal".into(), "at its normal".into()]), Field::Undeclared => (vec![UNDECLARED.into()], vec![""], vec![String::new()]), Field::Room => (vec![ROOM.into()], vec![""], vec![String::new()]), + Field::Integrity { traits, .. } => ((0..=*traits).map(|c| integrity_level(c, *traits)).collect(), { let mut s = s3(); s.push(""); s }, num_dirs()), } } #[cfg(test)] @@ -194,7 +204,8 @@ pub fn vocabulary() -> Vec { let mut v: Vec = SIZES.iter().map(|s| s.to_string()).collect(); v.extend([FIRST, HOLDING, "rising", "falling", "leaning toward", "fixed", "above its normal", "below its normal", "at its normal", "not released", "not reported", "pressing", "not pressing", "gate open", "gate closed", "even", "none yet", "toward", "none open", "open", "in force and", "bound", "read", "not read", UNDECLARED, ROOM, - "deficit", "g", "rbar", "p", "z", "needs", "surprise gate", "pace", "dispositions", "credit", "habits", "room left in the bound", "learned", "need", "sense", "mood", "stance", "average reward"].map(String::from)); + "deficit", "g", "rbar", "p", "z", "needs", "surprise gate", "pace", "dispositions", "credit", "habits", "room left in the bound", "learned", "need", "sense", "mood", "stance", "average reward", + "within warn", "past warn on", "of", "used", "drift", "integrity (drift from the declared self)"].map(String::from)); for g in ["optimism", "patience", "volatility"] { for x in [1.0, -1.0, 0.0] { v.push(gene_level(g, x).to_string()); } v.push(g.to_string()); } v } @@ -225,7 +236,8 @@ mod tests { v.push(Field::Learned { levels: ls.iter().map(|s| s.to_string()).collect(), deltas: vec![], cap: 0.25 }); } v.extend([Field::Need { deficit: 0.0, cap: 1.0, term: 0.0 }, Field::Gate { g: 1.0, g_max: 3.0 }, Field::Pace { level: "steady".into(), rbar: 0.0, cap: 4.0 }, - Field::Credit { open: 0, max: 8 }, Field::Habits { active: 0, bound: 0, declared: 11 }, Field::Sense { z: None }, Field::Undeclared, Field::Room]); + Field::Credit { open: 0, max: 8 }, Field::Habits { active: 0, bound: 0, declared: 11 }, Field::Sense { z: None }, Field::Undeclared, Field::Room, + Field::Integrity { used: 0.0, drift: 0.0, crossed: 0, traits: 2 }]); for g in ["optimism", "patience", "volatility"] { v.push(Field::Gene { gene: g.into(), x: 0.0 }); } v } @@ -282,7 +294,7 @@ mod tests { for w in vocabulary() { assert!(law_hits(&w).is_empty(), "{w}"); } let mut n = 0; for f in kinds() { let (ls, ss, ds) = space(&f); for l in &ls { for s in &ss { for d in &ds { let t = line("x", l, s, d); assert!(law_hits(&t).is_empty(), "{t}"); n += 1; } } } } - for label in ["needs", "surprise gate", "pace", "dispositions", "credit", "habits", "room left in the bound"] { assert!(law_hits(label).is_empty()); } + for label in ["needs", "surprise gate", "pace", "dispositions", "credit", "habits", "room left in the bound", "integrity (drift from the declared self)"] { assert!(law_hits(label).is_empty()); } assert!(n > 1000); } /// Words are a function of the buckets: a small move inside a bucket changes the dial, never the words diff --git a/probbit-cli/tests/fixtures/monitor/tutor-self.yaml b/probbit-cli/tests/fixtures/monitor/tutor-self.yaml new file mode 100644 index 0000000..9a17e57 --- /dev/null +++ b/probbit-cli/tests/fixtures/monitor/tutor-self.yaml @@ -0,0 +1,190 @@ +# A fictional persona: the playful tutor with learning, a bound block and the identity self keys (RFC 0014), for the integrity dial test. +probbit_persona: 1 +identity: + name: Pip + version: 1.1.0 + seed: 1 + keep: [no_jokes_on_loss, after_error_check] + warn: 0.5 + cue: hold the line + 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: no_play_when_upset + when: {sentiment: negative} + then: {humour: {at_most: light}} + say: go easy on the jokes # added in 1.1.0: one upset message could make some individuals playful (found by `fuzz`) + - 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] + +learning: + from: [praise, criticism] + traits: [humour, verbosity] + rate: 0.5 + step_cap: 0.2 + total_cap: 1 +reward_from: [human, env] +bound: + rest_hours: 200 + seeds: 0-9 diff --git a/probbit-cli/tests/monitor.rs b/probbit-cli/tests/monitor.rs index 95d358d..efa5d5e 100644 --- a/probbit-cli/tests/monitor.rs +++ b/probbit-cli/tests/monitor.rs @@ -558,3 +558,32 @@ fn the_mirror_serves_the_reflection() { assert_eq!(get(port, &format!("/s/{id}/reflection?last=10", ), &host).0, "HTTP/1.1 200 OK"); drop(child); let _ = std::fs::remove_file(&f); } + +/// RFC 0014, the integrity dial through the serve path: a life of a persona with the identity self keys; the latest frame's +/// document carries the last line's `integrity` (canonical, byte for byte), its `integrity` word's dial is that line's `used`, +/// and the page carries the Mirror's drift table +#[test] +fn the_integrity_dial_reads_the_strands_number() { + use probbit_persona::json::{self, Json}; + let f = tmp("integrity"); let _ = std::fs::remove_file(&f); + let ev: String = (0..24).map(|k| format!("{{\"elapsed_hours\":0.5,\"src\":\"human\",\"{}\":true}}\n", if k % 5 == 4 { "criticism" } else { "praise" })).collect(); + let mut live = Command::new(env!("CARGO_BIN_EXE_probbit")).args(["live", "tests/fixtures/monitor/tutor-self.yaml", "--seed", "3", "--clock", "fixed", "--strand", &f]) + .stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn().unwrap(); + live.stdin.take().unwrap().write_all(ev.as_bytes()).unwrap(); assert!(live.wait().unwrap().success()); + let (c, out, _) = probbit(&["live", "verify", &f]); assert_eq!(c, 0, "{out}"); + let text = std::fs::read_to_string(&f).unwrap(); let last = json::parse(text.lines().last().unwrap()).unwrap(); + let ig = last.get("integrity").expect("the last line carries integrity").clone(); + let used = ig.get("used").and_then(Json::as_f64).unwrap(); assert!(used > 0.0, "{}", probbit_persona::persona::canon(&ig)); + let (child, port) = served(&[&f]); let host = format!("127.0.0.1:{port}"); + let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap(); s.set_read_timeout(Some(Duration::from_secs(20))).unwrap(); + write!(s, "GET /events HTTP/1.1\r\nHost: {host}\r\n\r\n").unwrap(); + let mut buf = String::new(); let evs = stream(&mut s, &mut buf, 2); + let frame = evs.iter().rev().find(|(_, d)| d.contains("\"words\"")).map(|(_, d)| json::parse(d).unwrap()).unwrap_or_else(|| panic!("{buf}")); + assert_eq!(frame.get("n").and_then(Json::as_f64), Some(24.0)); + let canon = probbit_persona::persona::canon; + assert_eq!(canon(frame.get("doc").and_then(|d| d.get("integrity")).unwrap()), canon(&ig), "the frame's document carries the line's number"); + let w = frame.get("words").and_then(Json::as_arr).unwrap().iter().find(|w| w.get("key").and_then(Json::as_str) == Some("integrity")).cloned().expect("the integrity word"); + assert_eq!(w.get("dial").and_then(Json::as_f64), Some(used.min(1.0)), "{}", canon(&w)); + assert!(get(port, "/", &host).2.contains("id=\"mirror-integrity\"")); + drop(s); drop(child); let _ = std::fs::remove_file(&f); +} From 5ed7526bfb16c3bfba66893ff550ea95a1c90b9c Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 21:04:58 -0700 Subject: [PATCH 46/61] =?UTF-8?q?docs:=20RFC=200014=20integrity=20(docs/rf?= =?UTF-8?q?cs/0014-integrity.md,=20implemented,=20off=20by=20default);=20d?= =?UTF-8?q?ocs/persona.md=20=C2=A72.19=20the=20identity=20self=20keys=20an?= =?UTF-8?q?d=20=C2=A75.9=20integrity;=20docs/monitor-app.md=20the=20dial?= =?UTF-8?q?=20and=20the=20Mirror=20table;=20docs/embedding.md=20integrity?= =?UTF-8?q?=20on=20a=20body=20(host=20ms,=20cache=20hits,=20nostd=20stack?= =?UTF-8?q?=20and=20heap:=20over=20the=202=20%=20/=208=20KB=20budget,=20sa?= =?UTF-8?q?id=20so;=20silicon=20not=20measured);=20CHANGELOG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 13 +++++++ docs/embedding.md | 19 +++++++++ docs/monitor-app.md | 3 ++ docs/persona.md | 49 +++++++++++++++++++++++ docs/rfcs/0014-integrity.md | 78 +++++++++++++++++++++++++++++++++++++ 5 files changed, 162 insertions(+) create mode 100644 docs/rfcs/0014-integrity.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 77bcb9a..301b207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project are documented here. The format follows ## Unreleased +- **Integrity** (RFC 0014, docs/rfcs/0014-integrity.md, docs/persona.md §2.19 and §5.9): four optional self keys in the + `identity` block (`keep`, `warn`, `cue`, `traits`; outside the persona digest) turn on, per live event, an exact account of + the drift from the declared self: per trait the total variation between the rest-probe odds of the individual rested (the + channels of the hour at the fresh values, what persists as it is) and of the seed's fresh individual, `bound_used` against + RFC 0004's epsilon at the seed, the dial's `used`, the `keep` habits held or not fired, and the persona's own `cue` past + `warn`; every number at 6 decimals. Library `probbit_persona::integrity::{compute, compute_cached, Integrity}` (std and + `nostd`); the rest probe is cached on the rested state's body without `turn`. +- **Integrity on the event line** (engine 0.14.0+): the stance document and the event line carry `integrity`; `live verify` + recomputes it and refuses an edited value naming the line; an engine before 0.14.0 reads such a persona as unsupported + (exit 5). A persona without the self keys writes every strand, stance and corpus byte for byte as before. +- **The monitor's integrity dial and the Mirror's drift table**: the instrument panel's "room left in the bound" placeholder + becomes the integrity dial when the stance carries `integrity`; the Mirror tab shows the per-trait drift, allowance and share + of the event on view. - **Self-report** (RFC 0013, docs/rfcs/0013-self-report.md, docs/persona.md §5.8 "Self-report"): `probbit reflect STRAND [--last N | --since-checkpoint] [--json | --text]` and `probbit_persona::reflect` (std and `nostd`): a canonical account of a window of a strand's events from its lines alone, no solve: the inputs set, the expectation's surprises (z recomputed from the diff --git a/docs/embedding.md b/docs/embedding.md index 23484dd..98d0d92 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -431,6 +431,25 @@ The heap peak is the parsed checkpoint lines (a checkpoint line of the recorded it is read); a body short of heap reflects a window with fewer checkpoint lines in it. The document is byte for byte the same from the std and `nostd` builds and the CLI (6/6 windows over the two strands). Not measured on silicon. +### Integrity on a body (RFC 0014) + +With the identity self keys (docs/persona.md §2.19), `Live::event` computes `integrity` after the turn +(`probbit_persona::integrity::compute_cached`, std and `nostd`): once per seed the fresh individual's rest probe and the +allowances (RFC 0004's vertex search at that seed), then per event one rest probe on the rested state, skipped when the rested +state's body (without `turn`) equals the last one. A rest probe is a whole turn, so the cost is (miss rate) x (one turn). +Measured on the x86_64 host (release, best of 3 lives), and the stack high-water and heap peak of one call on the `nostd` +build (painted below the caller, as above): + +| Life | Events | Host ms with / without | Share of the turn | Cache hits | Stack (miss / hit) | Heap peak (miss / hit) | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| a recorded day of a body (moods, senses, a learning and a bound block; no feedback that day) | 1,160 | 2,255.5 / 1,609.4 (std), 2,250.3 / 1,644.5 (`nostd`) | +40.1 % / +36.8 % | 0 of 1,160 | 20,952 B / 9,312 B | 96,315 B / 25,221 B | +| the tutor with learning and a bound block, feedback on most events | 200 | 219.4 / 95.9 (std) | +128.9 % | 1 of 200 | 19,680 B / 9,232 B | 121,010 B / 11,029 B | + +This is over RFC 0014's budget (2 % of a turn, 8 KB of stack): on the recorded day something that persists moves on every +event (the drift itself stays 0.000000 all day), so the cache never hits, and a miss carries a turn's own stack. Integrity is +off by default and costs nothing without the self keys; a body that declares them pays one turn per event today. Not measured +on silicon. + ### Measured on one board (ESP32-S3) First numbers from one ESP32-S3 at 240 MHz, the engine on one core shared with a 16 fps renderer, with the boxed setup path diff --git a/docs/monitor-app.md b/docs/monitor-app.md index 178503d..95362f4 100644 --- a/docs/monitor-app.md +++ b/docs/monitor-app.md @@ -45,6 +45,9 @@ either kind opens like any app. A copy you build yourself (below) has no quarant labels ([persona.md §5.8](persona.md#58-monitor-watch-an-individuals-inner-state)). A probbit that watches one strand at a time (0.12.1 and earlier, if you build the app around one) refuses that; the app then shows the first strand you chose (for a folder, its most recently changed `*.strand` file) and says so under the window title. +- **Integrity** (engine 0.14.0+, a persona with the identity self keys, [persona.md §5.9](persona.md#59-integrity-the-drift-from-the-declared-self-rfc-0014)): + the instrument panel shows the integrity dial (the drift from the declared self as a share of the certificate's allowance) + where "room left in the bound" stood, and the Mirror tab adds the per-trait drift table of the event on view. - The window title is the page's title. When the page's title starts with a glyph, the Dock icon shows that glyph as its badge. - File → Open Recent lists what you opened; the app reopens the last selection when it starts with nothing else to open. - View → Reload (⌘R) reloads the page; **Follow Latest** (⌘L) loads the page's root again, the latest event with no event diff --git a/docs/persona.md b/docs/persona.md index 69b857f..ac78a53 100644 --- a/docs/persona.md +++ b/docs/persona.md @@ -1031,6 +1031,28 @@ point (and its selector). What it is: a lookup from the stance's mood levels, active habits and deviation to a symbol, so a person reading the chat or the monitor sees the engine's state at a glance. What it is not: a measurement of anything beyond those fields. +### 2.19 Identity: the self keys (optional; RFC 0014) + +Engine 0.14.0 and later; an engine before it refuses a persona that declares them (`identity.keep: unknown field`; `live +verify` of such a strand exits 5, `unsupported`). Four optional keys in the `identity` block declare which part of the +individual is "the self" and turn on integrity (section 5.9). They move nothing (effects {}) and stay out of the persona +digest, as `bound` does: declaring them on a persona with a recorded life leaves every state as it was. + +```yaml +identity: + name: Pip + version: 1.1.0 + seed: 1 + keep: [no_jokes_on_loss, after_error_check] # identity-bearing habits: reported held or not fired per event + warn: 0.5 # a share of the allowance in (0, 1], default 0.8 + cue: hold the line # the persona's own words, handed to the host past warn (1-120 characters) + traits: [humour, verbosity] # the traits measured; default learning.traits, else every trait +``` + +Refusals: a `keep` entry that is not a habit id of the persona, a `traits` entry that is not a trait (a mood is refused), an +entry listed twice, `warn` outside (0, 1], an empty or multi-line `cue`. The allowance per trait comes from the persona's +`bound` block (section 2.16); without one every allowance is null and `bound_used` reads 0. + ## 3. Compilation: persona + state + inputs → one probbit IR program For turn t of an individual (seed s), with inputs x_t: @@ -1875,6 +1897,33 @@ at each request (`/reflection?last=N`, `/s/ID/reflection` per strand), and the l whether it recomputes equal; the tab shows the sentences, the top surprises with predicted vs measured, the habits that bound and the moods at the checkpoint samples, refreshed with the page's scan of the strand list. +### 5.9 Integrity: the drift from the declared self (RFC 0014) + +With the self keys (section 2.19), every live stance document and every event line carries `integrity`, computed after the +turn from the state it produced (docs/rfcs/0014-integrity.md): + +```json +{"allowance":0.902968,"crossed":[],"cue":null,"drift":0.214,"habits_held":{"held":[],"not_fired":["after_error_check","no_jokes_on_loss"]}, + "traits":{"humour":{"allowance":0.462081,"bound_used":0.21,"drift":0.097},"verbosity":{"allowance":0.440887,"bound_used":0.27,"drift":0.117}}, + "used":0.237,"warn":0.5} +``` + +- `traits..drift`: the total variation between the odds at the rest probe (section 2.16) of the individual rested (the + channels of the hour, such as moods with a half-life, the credit awaiting feedback, the last stance and the agenda's step, at the fresh individual's + values; everything that persists as it is) and of the fresh individual of the same seed. `drift` is the sum over the traits. +- `allowance`: epsilon at the individual's seed (null without a `bound` block or for an unlearned trait); `bound_used` = + drift / allowance; `used` = the summed drift over the traits with an allowance over the summed allowance: the monitor's + integrity dial. By RFC 0004's theorem `bound_used` stays at or below 1 (up to the 1e-6 rounding of amendment A7). +- `crossed`: the traits at or past `warn`; `cue`: `identity.cue` while `crossed` is not empty, else null. The host appends the + cue to the line it hands the model; the engine does not edit `line`, which the state's remembered stance digest covers. +- `habits_held`: of the `keep` habits, those that bound at this event and those that did not fire. + +Every number is at 6 decimals. Both stance digests leave `integrity` out; `live verify` recomputes it on every event line and +refuses an edited value naming the line ("the integrity differs"); `resume` takes it as written. The library call is +`probbit_persona::integrity::{compute, compute_cached, Integrity}` (std and `nostd`). In the monitor the integrity dial takes +the place of "room left in the bound" on the instrument panel, and the Mirror tab adds the per-trait drift table of the event +on view. + ## 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 diff --git a/docs/rfcs/0014-integrity.md b/docs/rfcs/0014-integrity.md new file mode 100644 index 0000000..5737707 --- /dev/null +++ b/docs/rfcs/0014-integrity.md @@ -0,0 +1,78 @@ +# RFC 0014: Integrity, the drift from the declared self + +Status: implemented (library and strand line), off by default; engine 0.14.0-dev on a draft PR · Changes: docs/persona.md +(the `identity` block's self keys, additive and outside the digest), one engine-gated field on event lines · Depends on: +RFC 0004 (the allowance is its epsilon), RFC 0013 (the Mirror tab, where the per-trait table is to be shown) + +## Summary + +Per event, one exact number and its decomposition, from the state and the persona document alone: how far the individual's +resting odds have moved from those of the self its persona declares, and how much of the persuadability certificate's +allowance that movement uses. Labelled **mechanism**: arithmetic over declared numbers, replayable from the strand, +recomputed by `live verify`. It adds nothing to the state and changes no turn. + +## The declaration + +Four optional keys in the existing `identity` block (the "self keys"): + +- `keep: [habit ids]`: the habits that are identity-bearing. +- `warn: `: a share of the allowance in (0, 1], default 0.8. +- `cue: `: a short line (1-120 characters) in the persona's own words, handed to the host when a trait reaches `warn`. + The engine never writes cue text of its own. +- `traits: [trait ids]`: the traits measured; default the learned traits (`learning.traits`), else every trait. + +Declaring any of them turns integrity on. The keys are read against the persona built without them and stay out of the +persona digest (as RFC 0004's `bound`, amendment A1): every state is byte for byte as without them. A persona that declares +none of them emits nothing, so every existing strand, stance and corpus is unchanged. + +## The numbers + +- **The rest probe** (RFC 0004's): one event of `bound.rest_hours` idle hours (default 30 × the longest declared half-life, + 24 without one), no inputs. +- **The rested individual**: the current state with the channels of the hour at the fresh individual's values (moods with + `half_life_hours`, the pace average, the load, the rest pressure, the eligibility trace, the credit awaiting feedback and + its open decisions, the levels and stance taken last, the agenda's step) and everything that persists as it is (learned + deltas, history, moods without a half-life, drives, senses, the surprise gates, genes). The credit is of the hour: it is + what the next feedback would act on, zeroed by every control line, and a rest probe has no feedback. +- **drift**, per trait: the total variation, half the L1 distance over the trait's levels, between the probe's odds for the + rested individual and for the fresh individual of the same seed (the declared persona at birth); `drift` at the top is the + sum over the traits. The fresh individual is its own rested state, so its drift is 0 exactly. Total variation is chosen over the + plain L1 because it is the certificate's own unit: `bound_used` is then a ratio of like quantities, and the theorem of + RFC 0004 reads directly as `bound_used ≤ 1` (up to the residue r of decayed channels and the 1e-6 rounding of amendment A7). +- **allowance**, per trait: epsilon at the individual's own seed, computed once per seed from the persona's `bound` block + (the same vertex search as `persona bound --audit`); null for a trait that is not learned or without a `bound` block. +- **bound_used** = drift / allowance (0 without an allowance). **used** (the dial) = the summed drift over the traits with an + allowance, divided by the summed allowance (0 without one). +- **crossed**: the traits whose `bound_used` reached `warn`; **cue**: `identity.cue` when `crossed` is not empty, else null. +- **habits_held**: of the `keep` habits, `held` (bound this event) and `not_fired`. + +Every number is rounded to 6 decimals. + +## Where it appears + +- The live stance document gets `integrity`; the event line carries the same document as its `integrity` field. Both stance + digests leave it out (it is computed after the turn, from the state the turn produced, so the state's remembered stance + digest and `for` credit binding are unchanged). +- `live verify` recomputes it on every event line and refuses an edited value naming the line ("the integrity differs"). + `resume` takes it as written, as it takes `deviation` and `glyph`. +- Engine-gated: a persona with self keys is refused by an engine before 0.14.0 as an unknown field, which `live verify` + reports as `unsupported` (exit 5), never as a divergence. + +## Cost + +One extra exact solve per event at most, the rest probe of the rested individual, cached on the rested state's body without +`turn` and `digest` (a rested state has no open decision, so nothing in it ages in turns); the fresh individual's +probe and the allowances once per seed. Because the rested state leaves out the channels of the hour, the cache hits on every +event that moves only moods, the last stance or the agenda, and misses when something that persists moved (a learning step, +a credit, a history window, a drive). Measured (docs/embedding.md "Integrity on a body"): on a body's recorded day of 1,160 +events it still missed on every event (a persisting channel moves each event while the drift stays 0.000000), +40 % of a +turn on the host and a turn's stack (about 21 KB on the `nostd` build) per miss. The bar of this RFC (2 % of a turn, 8 KB) +is not met; a cache keyed on the rest probe's program, as the expectation's cache keys its members, is the next step. + +## Not in this build + +- The cost bar: a rest probe whose cache hits on a body's ordinary event (see Cost). + +- Appending the cue to the stance's `line` (the line is inside the stance digest the state remembers); the host appends + `integrity.cue` itself. +- The monitor's integrity dial and the Mirror's per-trait table. From 3bd4d7bb45839acd9430c34cc81efe0456e19e6c Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 21:31:15 -0700 Subject: [PATCH 47/61] probbit-persona integrity (RFC 0014-b): the rest probe's cache keyed twice: by the rested state (its body without turn, now hashed by structure) and by the probe's first program without its comment (the members' key): the probe's steps run as turn_boxed runs them, the program is compiled and keyed, an equal key answers from the row, else the turn finishes on that same program (turn_solve takes it: a miss compiles once); a row only for an exact first answer with no yield, hold or conflict, so every number is the uncached computation's --- probbit-persona/src/integrity.rs | 59 ++++++++++++++++++++++++-------- probbit-persona/src/persona.rs | 10 +++--- 2 files changed, 50 insertions(+), 19 deletions(-) diff --git a/probbit-persona/src/integrity.rs b/probbit-persona/src/integrity.rs index 896e483..1a4dfdd 100644 --- a/probbit-persona/src/integrity.rs +++ b/probbit-persona/src/integrity.rs @@ -51,22 +51,45 @@ pub fn spec(idn: &Json, doc: &Json, p: &Persona) -> R { #[derive(Clone, Debug, PartialEq)] pub struct Integrity { pub doc: Json, pub drift: f64, pub used: f64 } -/// The fresh individual's odds at the rest probe and the allowances (both fixed per seed), and the last probe by state (the -/// state's body without its `turn` and digest: a turn that changes nothing else leaves the resting odds as they were) -pub struct Cache { base: Option<(u64, Vec>, Vec>, State)>, key: String, odds: Vec>, pub hits: u64, pub misses: u64 } -impl Cache { pub const fn new() -> Cache { Cache { base: None, key: String::new(), odds: Vec::new(), hits: 0, misses: 0 } } } +/// The fresh individual's odds at the rest probe and the allowances (both fixed per seed), and the last probe twice keyed: by +/// state (the rested state's body without its `turn` and digest: a turn that changes nothing else leaves the resting odds as they +/// were) and by program (the probe's first program without its comment, `prog_key` as the members' cache keys them: a rested state +/// whose senses or drives moved but whose resting program did not gives the same odds). A program row is kept only when that +/// program's answer was exact and the stance is that answer's (no yield, hold or conflict): exact marginals read the program alone. +pub struct Cache { base: Option<(u64, Vec>, Vec>, State)>, key: [u8; 32], odds: Vec>, prog: Option<[u8; 32]>, + /// probes answered from the cache (by state or by program), probes solved, and of the hits those by program + pub hits: u64, pub misses: u64, pub by_program: u64 } +impl Cache { pub const fn new() -> Cache { Cache { base: None, key: [0; 32], odds: Vec::new(), prog: None, hits: 0, misses: 0, by_program: 0 } } } impl Default for Cache { fn default() -> Cache { Cache::new() } } +/// The rest probe's inputs: one event of `id.rest` idle hours +fn rest_in(id: &Ident) -> Json { Json::Obj(vec![("elapsed_hours".into(), Json::Num(id.rest))]) } +/// Per measured trait its odds in level order, read from a stance document +fn odds_of(p: &Persona, doc: &Json, id: &Ident) -> Vec> { + id.traits.iter().map(|t| { let o = doc.get("stance").and_then(|s| s.get(t)).and_then(|x| x.get("odds")); + p.var(t).map_or(vec![], |v| v.levels.iter().map(|lv| o.and_then(|o| o.get(lv)).and_then(Json::as_f64).unwrap_or(0.0)).collect()) }).collect() +} /// The rest probe: one event of `id.rest` idle hours, no inputs -> per measured trait its odds in level order fn probe(p: &Persona, st: &State, id: &Ident, eng: Engine) -> R>> { - let (doc, _) = turn(p, st, &Json::Obj(vec![("elapsed_hours".into(), Json::Num(id.rest))]), false, eng, false)?; - Ok(id.traits.iter().map(|t| { let o = doc.get("stance").and_then(|s| s.get(t)).and_then(|x| x.get("odds")); - p.var(t).map_or(vec![], |v| v.levels.iter().map(|lv| o.and_then(|o| o.get(lv)).and_then(Json::as_f64).unwrap_or(0.0)).collect()) }).collect()) + let (doc, _) = turn_boxed(p, st, &rest_in(id), false, eng, false)?; Ok(odds_of(p, &doc, id)) +} +/// The rest probe through the program key: the turn's steps as `turn_boxed` runs them, its first program compiled here and keyed; +/// when the key is `known` -> (key, None), else the turn finishes on that program -> (key, Some((odds, whether a row may keep them))) +#[inline(never)] +fn probe_keyed(p: &Persona, st: &State, id: &Ident, eng: Engine, known: Option<&[u8; 32]>) -> R<([u8; 32], Option<(Vec>, bool)>)> { + let pr = turn_prep(p, st, &rest_in(id), true)?; + let first = Box::new(compile(p, &pr.st, &pr.raw, &Opts::default())?); + let key = prog_key(&first.0); + if known == Some(&key) { return Ok((key, None)); } + let sv = turn_solve(p, &pr, false, eng, false, false, Some(first))?; + let o = &sv.out; let row = o.verdict.as_str() == Some("exact") && o.held.is_empty() && o.yielded.is_empty() && o.conflict.is_empty(); + let (doc, _) = turn_doc(p, pr, sv); Ok((key, Some((odds_of(p, &doc, id), row)))) } /// The cache key of a rested state: its body without `turn` and `digest` (a rested state has no open decision, whose credit -/// would age in turns) -fn key_of(p: &Persona, st: &State) -> String { - let mut b = st.body(p); if let Json::Obj(v) = &mut b { v.retain(|(k, _)| k != "turn" && k != "digest"); } canon(&b) +/// would age in turns), hashed by structure (`prog_key`) +#[inline(never)] +fn key_of(p: &Persona, st: &State) -> [u8; 32] { + let mut b = st.body(p); if let Json::Obj(v) = &mut b { v.retain(|(k, _)| k != "turn" && k != "digest"); } prog_key(&b) } /// The individual rested: the channels of the hour at the fresh individual's values `st0` (moods with a half-life, the pace /// average, the load, the rest pressure, the eligibility trace and the credit awaiting feedback with its open decisions, the @@ -82,8 +105,9 @@ pub fn rested(p: &Persona, st: &State, st0: &State) -> State { /// `integrity` of the state `st` (after a turn) whose stance document is `stance` (its `habits.bound`), from a fresh cache pub fn compute(p: &Persona, st: &State, stance: &Json, eng: Engine) -> R> { compute_cached(p, st, stance, &mut Cache::new(), eng) } -/// `compute` through a cache: one rest-probe solve when the state's body (without `turn`) changed since the last call, else none; -/// the fresh individual's probe and the allowances once per seed. None without the self keys. +/// `compute` through a cache: one rest-probe solve when the state's body (without `turn`) and the probe's program (without its +/// comment) both changed since the last call, else none; the fresh individual's probe and the allowances once per seed. None +/// without the self keys. pub fn compute_cached(p: &Persona, st: &State, stance: &Json, cache: &mut Cache, eng: Engine) -> R> { let Some(id) = p.ident.as_ref() else { return Ok(None) }; if cache.base.as_ref().map_or(true, |(s, _, _, _)| *s != st.seed) { @@ -95,11 +119,16 @@ pub fn compute_cached(p: &Persona, st: &State, stance: &Json, cache: &mut Cache, (Some(b), Some(l), Some(k)) => { let sc = bound::scope(p, b, t, id.rest, Some(&st0)); match bound::bases(p, &sc, &st0) { Some(bs) => Some(bound::search(p, k, &sc.coords, l.total_cap, &bs, &p0[i], id.rest, eng)?.0), None => None } } _ => None }); } - cache.base = Some((st.seed, p0, eps, st0)); cache.key.clear(); + cache.base = Some((st.seed, p0, eps, st0)); cache.key = [0; 32]; cache.prog = None; } - let rs = rested(p, st, &cache.base.as_ref().unwrap().3); + let rs = Box::new(rested(p, st, &cache.base.as_ref().unwrap().3)); let key = key_of(p, &rs); - if key == cache.key { cache.hits += 1; } else { cache.odds = probe(p, &rs, id, eng)?; cache.key = key; cache.misses += 1; } + if key == cache.key { cache.hits += 1; } else { + cache.key = key; + match probe_keyed(p, &rs, id, eng, cache.prog.as_ref())? { + (_, None) => { cache.hits += 1; cache.by_program += 1; } + (k, Some((odds, row))) => { cache.odds = odds; cache.prog = row.then_some(k); cache.misses += 1; } } } + drop(rs); let (_, p0, eps, _) = cache.base.as_ref().unwrap(); let (mut sum, mut sum_e, mut sum_d) = (0.0f64, 0.0f64, 0.0f64); let mut rows = vec![]; let mut crossed = vec![]; for (i, t) in id.traits.iter().enumerate() { diff --git a/probbit-persona/src/persona.rs b/probbit-persona/src/persona.rs index 3690cdd..1d6bdf1 100644 --- a/probbit-persona/src/persona.rs +++ b/probbit-persona/src/persona.rs @@ -2940,7 +2940,7 @@ fn turn_src_at(p: &Persona, st: &State, raw: &Json, no_inertia: bool, eng: Engin let pr = turn_prep(p, st, raw, check)?; // the deviation's A_t is `program_meta` of the same state and inputs: no rest, `src` taken out only with these blocks let want = want && check && pr.fresh && !no_inertia && p.eng.op == "decide" && (p.reward.is_none() || p.senses.is_some() || p.credit().is_some() || p.has_needs()); - let mut sv = turn_solve(p, &pr, no_inertia, eng, timing, want)?; let at = sv.at.take(); + let mut sv = turn_solve(p, &pr, no_inertia, eng, timing, want, None)?; let at = sv.at.take(); let (j, s) = turn_doc(p, pr, sv); Ok((j, s, at)) } /// The steps before the compiler: the inputs read, `src` and the payments taken out, a rest due applied, then the sense / needs / @@ -2985,15 +2985,17 @@ fn decode_boxed(p: &Persona, st: &State, program: &str, meta: &Meta, doc: &Json, #[inline(never)] fn settle_boxed(p: &Persona, j: &mut Json, ns: Box, k: &Pay, s: Settled) -> Box { Box::new(settle(p, j, *ns, k, s)) } /// The compiler, the engine and the decoder: the program for the stepped state, its solve (a yield on a habit conflict, a second, -/// clamped solve for the persona's vouch rule) and the answer read back +/// clamped solve for the persona's vouch rule) and the answer read back; `first`: the first program already compiled from the same +/// stepped state and inputs with these options (RFC 0014's rest probe keys its cache on it), so it is not compiled twice #[inline(never)] -fn turn_solve(p: &Persona, pr: &Prepped, no_inertia: bool, eng: Engine, timing: bool, want: bool) -> R { +#[allow(clippy::too_many_arguments)] +fn turn_solve(p: &Persona, pr: &Prepped, no_inertia: bool, eng: Engine, timing: bool, want: bool, first: Option>) -> R { use crate::trace::{enter, Phase}; let (st, raw): (&State, &[(String, Json)]) = (&pr.st, &pr.raw); enter(Phase::Compile); 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 (mut prog, mut meta) = match first { Some(b) => *b, None => 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); // the expectation's A_t: this answer's exact marginals (an exact answer never yields, so `meta` stays this program's) let first = want.then(|| doc_marg(&doc)); From 650e115fac451234098975da3d747713b4a3a422 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 21:37:19 -0700 Subject: [PATCH 48/61] probbit-persona integrity (RFC 0014-b): the probe's path on the heap: the rested state boxed (rested_boxed), the fresh individual boxed (init_boxed) in its own frame (base), the cache step (resting) and the document (document) out of line, so no frame of a miss holds a State by value and compute_cached's frame is small; test the_program_key_moves_no_number_when_the_senses_move (sensed fixture with the self keys, senses moving every event: every number equals the uncached computation's) --- probbit-persona/src/integrity.rs | 79 ++++++++++++++++++++++---------- 1 file changed, 56 insertions(+), 23 deletions(-) diff --git a/probbit-persona/src/integrity.rs b/probbit-persona/src/integrity.rs index 1a4dfdd..abe3e7b 100644 --- a/probbit-persona/src/integrity.rs +++ b/probbit-persona/src/integrity.rs @@ -56,12 +56,14 @@ pub struct Integrity { pub doc: Json, pub drift: f64, pub used: f64 } /// were) and by program (the probe's first program without its comment, `prog_key` as the members' cache keys them: a rested state /// whose senses or drives moved but whose resting program did not gives the same odds). A program row is kept only when that /// program's answer was exact and the stance is that answer's (no yield, hold or conflict): exact marginals read the program alone. -pub struct Cache { base: Option<(u64, Vec>, Vec>, State)>, key: [u8; 32], odds: Vec>, prog: Option<[u8; 32]>, +pub struct Cache { base: Option>, key: [u8; 32], odds: Vec>, prog: Option<[u8; 32]>, /// probes answered from the cache (by state or by program), probes solved, and of the hits those by program pub hits: u64, pub misses: u64, pub by_program: u64 } impl Cache { pub const fn new() -> Cache { Cache { base: None, key: [0; 32], odds: Vec::new(), prog: None, hits: 0, misses: 0, by_program: 0 } } } impl Default for Cache { fn default() -> Cache { Cache::new() } } +/// Per seed: the seed, the fresh individual's odds at the rest probe, the allowances, the fresh individual +type Base = (u64, Vec>, Vec>, State); /// The rest probe's inputs: one event of `id.rest` idle hours fn rest_in(id: &Ident) -> Json { Json::Obj(vec![("elapsed_hours".into(), Json::Num(id.rest))]) } /// Per measured trait its odds in level order, read from a stance document @@ -95,8 +97,11 @@ fn key_of(p: &Persona, st: &State) -> [u8; 32] { /// average, the load, the rest pressure, the eligibility trace and the credit awaiting feedback with its open decisions, the /// levels and stance taken last, the agenda's step); what persists as it is (learned deltas, history, moods without a /// half-life, drives, senses, the surprise gates, genes) -pub fn rested(p: &Persona, st: &State, st0: &State) -> State { - let mut s = st.clone(); +pub fn rested(p: &Persona, st: &State, st0: &State) -> State { *rested_boxed(p, st, st0) } +/// `rested` on the heap (the probe's path: no frame holds a `State` by value) +#[inline(never)] +pub fn rested_boxed(p: &Persona, st: &State, st0: &State) -> Box { + let mut s = Box::new(st.clone()); for (id, acc) in s.mood.iter_mut() { if p.var(id).is_some_and(|v| v.half_life.is_some()) { if let Some((_, a0)) = st0.mood.iter().find(|(k, _)| k == id) { acc.clone_from(a0); } } } s.rbar = st0.rbar; s.load_l = st0.load_l; s.pressure = st0.pressure; s.elig = st0.elig; s.prev.clone_from(&st0.prev); s.prev_stance.clone_from(&st0.prev_stance); s.agenda.clone_from(&st0.agenda); @@ -110,26 +115,39 @@ pub fn compute(p: &Persona, st: &State, stance: &Json, eng: Engine) -> R R> { let Some(id) = p.ident.as_ref() else { return Ok(None) }; - if cache.base.as_ref().map_or(true, |(s, _, _, _)| *s != st.seed) { - let st0 = init(p, Some(st.seed), true, eng); let p0 = probe(p, &st0, id, eng)?; - let mut eps = vec![]; - for (i, t) in id.traits.iter().enumerate() { - let k = p.learning.as_ref().and_then(|l| l.traits.iter().position(|x| x == t)); - eps.push(match (&id.bound, &p.learning, k) { - (Some(b), Some(l), Some(k)) => { let sc = bound::scope(p, b, t, id.rest, Some(&st0)); - match bound::bases(p, &sc, &st0) { Some(bs) => Some(bound::search(p, k, &sc.coords, l.total_cap, &bs, &p0[i], id.rest, eng)?.0), None => None } } - _ => None }); } - cache.base = Some((st.seed, p0, eps, st0)); cache.key = [0; 32]; cache.prog = None; - } - let rs = Box::new(rested(p, st, &cache.base.as_ref().unwrap().3)); + if cache.base.as_ref().map_or(true, |b| b.0 != st.seed) { cache.base = Some(base(p, st.seed, id, eng)?); cache.key = [0; 32]; cache.prog = None; } + resting(p, st, id, cache, eng)?; + document(stance, id, cache).map(Some) +} +/// The fresh individual of `seed`: its rest probe and the allowances (on the heap, once per seed) +#[inline(never)] +fn base(p: &Persona, seed: u64, id: &Ident, eng: Engine) -> R> { + let st0 = init_boxed(p, Some(seed), true, eng); let p0 = probe(p, &st0, id, eng)?; + let mut eps = vec![]; + for (i, t) in id.traits.iter().enumerate() { + let k = p.learning.as_ref().and_then(|l| l.traits.iter().position(|x| x == t)); + eps.push(match (&id.bound, &p.learning, k) { + (Some(b), Some(l), Some(k)) => { let sc = bound::scope(p, b, t, id.rest, Some(&*st0)); + match bound::bases(p, &sc, &st0) { Some(bs) => Some(bound::search(p, k, &sc.coords, l.total_cap, &bs, &p0[i], id.rest, eng)?.0), None => None } } + _ => None }); } + Ok(Box::new((seed, p0, eps, *st0))) +} +/// The resting odds of `st` into `cache.odds`: from the state key, else from the program key, else one rest probe +#[inline(never)] +fn resting(p: &Persona, st: &State, id: &Ident, cache: &mut Cache, eng: Engine) -> R<()> { + let rs = rested_boxed(p, st, &cache.base.as_ref().unwrap().3); let key = key_of(p, &rs); - if key == cache.key { cache.hits += 1; } else { - cache.key = key; - match probe_keyed(p, &rs, id, eng, cache.prog.as_ref())? { - (_, None) => { cache.hits += 1; cache.by_program += 1; } - (k, Some((odds, row))) => { cache.odds = odds; cache.prog = row.then_some(k); cache.misses += 1; } } } - drop(rs); - let (_, p0, eps, _) = cache.base.as_ref().unwrap(); + if key == cache.key { cache.hits += 1; return Ok(()); } + cache.key = key; + match probe_keyed(p, &rs, id, eng, cache.prog.as_ref())? { + (_, None) => { cache.hits += 1; cache.by_program += 1; } + (k, Some((odds, row))) => { cache.odds = odds; cache.prog = row.then_some(k); cache.misses += 1; } } + Ok(()) +} +/// The integrity document from the resting odds in `cache` and the stance's bound habits +#[inline(never)] +fn document(stance: &Json, id: &Ident, cache: &Cache) -> R { + let b = cache.base.as_ref().unwrap(); let (p0, eps) = (&b.1, &b.2); let (mut sum, mut sum_e, mut sum_d) = (0.0f64, 0.0f64, 0.0f64); let mut rows = vec![]; let mut crossed = vec![]; for (i, t) in id.traits.iter().enumerate() { let d = r6(bound::tv(&cache.odds[i], &p0[i])); sum += d; @@ -146,7 +164,7 @@ pub fn compute_cached(p: &Persona, st: &State, stance: &Json, cache: &mut Cache, ("habits_held".into(), Json::Obj(vec![("held".into(), jstrs(&held)), ("not_fired".into(), jstrs(¬_fired))])), ("cue".into(), cue)]); // canonical key order (the order the strand line is written in), so the stance's copy equals the line's let doc = json::parse(&canon(&doc)).map_err(|e| perr("integrity", e.msg))?; - Ok(Some(Integrity { doc, drift, used })) + Ok(Integrity { doc, drift, used }) } #[cfg(test)] @@ -193,6 +211,21 @@ mod tests { compute_cached(&p, &s, &d, &mut c, &run).unwrap().unwrap(); } assert_eq!((c.hits - before.0, c.misses - before.1), (5, 0), "five mood-only events: five hits"); } + /// RFC 0014-b: senses that move every event (the rested state's key never repeats): through the program key every number + /// equals the uncached computation's + #[cfg(feature = "std")] + #[test] + fn the_program_key_moves_no_number_when_the_senses_move() { + let f = concat!(env!("CARGO_MANIFEST_DIR"), "/../bench/bound/fixtures/sensed.yaml"); + let mut d = crate::yaml::load(&std::fs::read_to_string(f).unwrap()).unwrap_or_else(|e| panic!("{}", e.msg)); + if let Json::Obj(v) = &mut d { if let Some((_, Json::Obj(i))) = v.iter_mut().find(|(k, _)| k == "identity") { i.push(("warn".into(), Json::Num(0.8))); } } + let p = build(&d).unwrap(); let mut c = Cache::new(); let mut s = init(&p, Some(3), true, &run); + for n in 0..40u32 { let e = format!(r#"{{"senses":{{"tool_latency":{},"tempo":{}}},"src":"env","elapsed_hours":0.1{}}}"#, 0.5 + f64::from(n % 7) * 0.37, n % 5, if n % 9 == 4 { r#","praise":true"# } else { "" }); + let (doc, ns) = turn(&p, &s, &json::parse(&e).unwrap(), false, &run, false).unwrap(); s = ns; + let i = compute_cached(&p, &s, &doc, &mut c, &run).unwrap().unwrap(); + assert_eq!(canon(&i.doc), canon(&compute(&p, &s, &doc, &run).unwrap().unwrap().doc), "event {n}: the cache gives the uncached number"); } + eprintln!("R14B sensed: hits {} (by program {}) misses {}", c.hits, c.by_program, c.misses); + } /// B3: every repo persona (examples/persona, bench/bound/fixtures) with the self keys declared: a fresh individual drifts 0 #[cfg(feature = "std")] #[test] From a2456d854669972f2eca54ead5bb05941f54f85d Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 21:52:58 -0700 Subject: [PATCH 49/61] probbit-persona integrity (RFC 0014-b): while the last solved probe left a program row the rested state's key is not built (an equal rested state compiles the same program, so the row answers the same); after a solve without a row the state key is that state's, as before: every number as before, the day's cost one state serialisation lower per event --- probbit-persona/src/integrity.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/probbit-persona/src/integrity.rs b/probbit-persona/src/integrity.rs index abe3e7b..7be780a 100644 --- a/probbit-persona/src/integrity.rs +++ b/probbit-persona/src/integrity.rs @@ -110,8 +110,8 @@ pub fn rested_boxed(p: &Persona, st: &State, st0: &State) -> Box { /// `integrity` of the state `st` (after a turn) whose stance document is `stance` (its `habits.bound`), from a fresh cache pub fn compute(p: &Persona, st: &State, stance: &Json, eng: Engine) -> R> { compute_cached(p, st, stance, &mut Cache::new(), eng) } -/// `compute` through a cache: one rest-probe solve when the state's body (without `turn`) and the probe's program (without its -/// comment) both changed since the last call, else none; the fresh individual's probe and the allowances once per seed. None +/// `compute` through a cache: one rest-probe solve when the probe's program (without its comment) and the state's body (without +/// `turn`) both changed since the last solve, else none; the fresh individual's probe and the allowances once per seed. None /// without the self keys. pub fn compute_cached(p: &Persona, st: &State, stance: &Json, cache: &mut Cache, eng: Engine) -> R> { let Some(id) = p.ident.as_ref() else { return Ok(None) }; @@ -132,16 +132,17 @@ fn base(p: &Persona, seed: u64, id: &Ident, eng: Engine) -> R> { _ => None }); } Ok(Box::new((seed, p0, eps, *st0))) } -/// The resting odds of `st` into `cache.odds`: from the state key, else from the program key, else one rest probe +/// The resting odds of `st` into `cache.odds`: from the program key while the last solved probe left a row (an equal rested state +/// would compile the same program, so the state key would answer the same: it is not built), else from the state key, else one +/// rest probe; after a solve without a row the state key is that state's, as before #[inline(never)] fn resting(p: &Persona, st: &State, id: &Ident, cache: &mut Cache, eng: Engine) -> R<()> { let rs = rested_boxed(p, st, &cache.base.as_ref().unwrap().3); - let key = key_of(p, &rs); - if key == cache.key { cache.hits += 1; return Ok(()); } - cache.key = key; + if cache.prog.is_none() { let key = key_of(p, &rs); if key == cache.key { cache.hits += 1; return Ok(()); } cache.key = key; } match probe_keyed(p, &rs, id, eng, cache.prog.as_ref())? { (_, None) => { cache.hits += 1; cache.by_program += 1; } - (k, Some((odds, row))) => { cache.odds = odds; cache.prog = row.then_some(k); cache.misses += 1; } } + (k, Some((odds, row))) => { cache.odds = odds; cache.misses += 1; + if row { cache.prog = Some(k); } else if cache.prog.take().is_some() { cache.key = key_of(p, &rs); } } } Ok(()) } /// The integrity document from the resting odds in `cache` and the stance's bound habits From 46ab05cdff344b930b4fdd38cd8b652db3ab265a Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Tue, 27 Oct 2026 21:53:18 -0700 Subject: [PATCH 50/61] docs: RFC 0014-b integrity's cost: docs/rfcs/0014-integrity.md Cost (the program key and the state key; a recorded day 1,156 of 1,160 from the cache, +16.1 % of a turn from +40.1 %; 13,656 B per miss against 12,520 B for the bare rest-probe turn; the 2 % / 8 KB bar still not met, the floor and the remaining lever named), docs/embedding.md integrity on a body (the table measured again), CHANGELOG --- CHANGELOG.md | 7 ++++++- docs/embedding.md | 33 +++++++++++++++++++-------------- docs/rfcs/0014-integrity.md | 33 ++++++++++++++++++++++++--------- 3 files changed, 49 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 301b207..7cc449f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,12 @@ All notable changes to this project are documented here. The format follows channels of the hour at the fresh values, what persists as it is) and of the seed's fresh individual, `bound_used` against RFC 0004's epsilon at the seed, the dial's `used`, the `keep` habits held or not fired, and the persona's own `cue` past `warn`; every number at 6 decimals. Library `probbit_persona::integrity::{compute, compute_cached, Integrity}` (std and - `nostd`); the rest probe is cached on the rested state's body without `turn`. + `nostd`); the rest probe is cached by its program without the comment (the members' key; a row only for an exact answer) and, + while no row is held, by the rested state's body without `turn`. +- **Integrity's cost** (RFC 0014-b): on a body's recorded day the rest probe is answered from the cache 1,156 of 1,160 times + (from 0): +16.1 % of a turn on the host (from +40.1 %), 13,656 B on the `nostd` build per miss (from 20,952 B). Every + integrity number is byte for byte as before. Still over the 2 % / 8 KB budget: a hit builds the probe's program, a miss is a + turn (docs/embedding.md "Integrity on a body"). - **Integrity on the event line** (engine 0.14.0+): the stance document and the event line carry `integrity`; `live verify` recomputes it and refuses an edited value naming the line; an engine before 0.14.0 reads such a persona as unsupported (exit 5). A persona without the self keys writes every strand, stance and corpus byte for byte as before. diff --git a/docs/embedding.md b/docs/embedding.md index 98d0d92..897551f 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -434,21 +434,26 @@ from the std and `nostd` builds and the CLI (6/6 windows over the two strands). ### Integrity on a body (RFC 0014) With the identity self keys (docs/persona.md §2.19), `Live::event` computes `integrity` after the turn -(`probbit_persona::integrity::compute_cached`, std and `nostd`): once per seed the fresh individual's rest probe and the -allowances (RFC 0004's vertex search at that seed), then per event one rest probe on the rested state, skipped when the rested -state's body (without `turn`) equals the last one. A rest probe is a whole turn, so the cost is (miss rate) x (one turn). -Measured on the x86_64 host (release, best of 3 lives), and the stack high-water and heap peak of one call on the `nostd` -build (painted below the caller, as above): - -| Life | Events | Host ms with / without | Share of the turn | Cache hits | Stack (miss / hit) | Heap peak (miss / hit) | +(`probbit_persona::integrity::compute_cached`, std and `nostd`). Once per seed it runs the fresh individual's rest probe and +computes the allowances (RFC 0004's vertex search at that seed). Then, per event, it handles the rest probe on the rested +state. The probe's turn steps run, its first program is compiled and keyed by structure without its `comment`, and an equal key +answers from the row the last exact probe left. Otherwise the turn finishes on that program. A row is kept only for an exact +answer with no yield, hold or conflict, so every number is the uncached computation's: with the self keys declared, the strands +of the lives below are byte for byte those of the build before the program key. A hit costs one program build; a miss costs one +turn. The probe's path is boxed (the rested state, the fresh individual, the cache step and the document each in a frame of +their own). Measured on the x86_64 host (release, best of 3 lives), and the stack high-water and heap peak of one call on the +`nostd` build (painted below the caller, as above): + +| Life | Events | Host ms with / without | Share of the turn | Answered from the cache | Stack (miss / hit) | Heap peak (miss / hit) | | --- | ---: | ---: | ---: | ---: | ---: | ---: | -| a recorded day of a body (moods, senses, a learning and a bound block; no feedback that day) | 1,160 | 2,255.5 / 1,609.4 (std), 2,250.3 / 1,644.5 (`nostd`) | +40.1 % / +36.8 % | 0 of 1,160 | 20,952 B / 9,312 B | 96,315 B / 25,221 B | -| the tutor with learning and a bound block, feedback on most events | 200 | 219.4 / 95.9 (std) | +128.9 % | 1 of 200 | 19,680 B / 9,232 B | 121,010 B / 11,029 B | - -This is over RFC 0014's budget (2 % of a turn, 8 KB of stack): on the recorded day something that persists moves on every -event (the drift itself stays 0.000000 all day), so the cache never hits, and a miss carries a turn's own stack. Integrity is -off by default and costs nothing without the self keys; a body that declares them pays one turn per event today. Not measured -on silicon. +| a recorded day of a body (moods, senses, drives, needs, a learning block; no feedback that day) | 1,160 | 1,849.9 / 1,593.5 (std), 1,889.5 / 1,627.9 (`nostd`) | +16.1 % / +16.1 % | 1,156 of 1,160 (the resting program moved on 4 events) | 13,656 B / 13,656 B | 93,190 B / 68,748 B | +| the tutor with learning, feedback on most events | 200 | 195.9 / 97.0 (std) | +102.0 % | 88 of 200 (a learning step moves the program) | 12,784 B / 11,008 B | 119,933 B / 75,476 B | + +The bare rest-probe turn (`turn_boxed` on the rested state, no cache) reaches 12,520 B on the same build, so a miss is a turn's +stack plus 1,136 B of the probe's own frames. A hit by program runs the turn's steps before the solve, the deepest part of +a turn, so it reaches the same 13,656 B; a hit by state (a life where nothing that persists moved) 5,104 B. Before the program key the day cost +40.1 % / +36.8 % with 0 hits, +20,952 B per miss. This is still over RFC 0014's budget (2 % of a turn, 8 KB per miss): a hit still builds the probe's +program, and a miss is a turn. Integrity is off by default and costs nothing without the self keys. Not measured on silicon. ### Measured on one board (ESP32-S3) diff --git a/docs/rfcs/0014-integrity.md b/docs/rfcs/0014-integrity.md index 5737707..889ccb6 100644 --- a/docs/rfcs/0014-integrity.md +++ b/docs/rfcs/0014-integrity.md @@ -60,18 +60,33 @@ Every number is rounded to 6 decimals. ## Cost -One extra exact solve per event at most, the rest probe of the rested individual, cached on the rested state's body without -`turn` and `digest` (a rested state has no open decision, so nothing in it ages in turns); the fresh individual's -probe and the allowances once per seed. Because the rested state leaves out the channels of the hour, the cache hits on every -event that moves only moods, the last stance or the agenda, and misses when something that persists moved (a learning step, -a credit, a history window, a drive). Measured (docs/embedding.md "Integrity on a body"): on a body's recorded day of 1,160 -events it still missed on every event (a persisting channel moves each event while the drift stays 0.000000), +40 % of a -turn on the host and a turn's stack (about 21 KB on the `nostd` build) per miss. The bar of this RFC (2 % of a turn, 8 KB) -is not met; a cache keyed on the rest probe's program, as the expectation's cache keys its members, is the next step. +At most one extra solve per event: the rest probe of the rested individual. The fresh individual's probe and the +allowances are computed once per seed. The probe is cached twice (RFC 0014-b): + +- **By program.** The probe's turn steps run as the turn runs them, its first program is compiled and keyed by its structure + without the `comment` (the key the expectation's cache gives its members). An equal key answers from the row. Otherwise the + turn finishes on that same compiled program, so a miss compiles once. A row is kept only when the program's answer was exact + and the stance is that answer's (no yield, no hold's clamped solve, no conflict): exact marginals read the program's model + alone, so a row is the uncached computation's value bit for bit. A body whose senses, drives or needs move every event hits + whenever its resting program did not move. +- **By state**, as before: the rested state's body without `turn` and `digest`, used while no program row is held (after a + sampled answer). While a row is held an equal rested state would compile the same program, so the state key is not built. + +Measured (docs/embedding.md "Integrity on a body"): on a body's recorded day of 1,160 events the resting program moved on 4 +events (the `pursue` goal's drive terms), so the probe was answered from the cache 1,156 times (99.66 %). The cost fell from ++40.1 % to +16.1 % of a turn on the host (std and the `nostd` proxy alike): what remains on a hit is building the probe's program (the turn's steps before the +compiler, the compile, the key). A miss is a whole turn and carries its stack (13,656 B on the `nostd` build, against +12,520 B for the bare rest-probe turn); a hit by program reaches the same depth (13,656 B): the turn's steps before the solve +are the deepest part of a turn. The bar of this RFC (2 % of a turn, 8 KB per miss) is still not +met. The floor of a program key is one program build per event. The remaining lever is to skip the build when nothing the +probe's program reads has moved (a key on the compiler's inputs: the learned deltas, the credit, the history windows, the drives' +terms, the senses' gains). That key is not built here: its exactness needs the slice proved field by field, the way the +expectation's members would need it (T2's NEXT 1). A per-miss stack under 8 KB needs a rest probe lighter than a turn. ## Not in this build -- The cost bar: a rest probe whose cache hits on a body's ordinary event (see Cost). +- The cost bar (2 % of a turn, 8 KB per miss): the cache now hits on a body's ordinary event; what remains is one program + build per event and a turn's stack per miss (see Cost). - Appending the cue to the stance's `line` (the line is inside the stance digest the state remembers); the host appends `integrity.cue` itself. From ad3bd2b78ae31d0e4cee452ea924b554365ddd2e Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Wed, 28 Oct 2026 02:29:31 -0700 Subject: [PATCH 51/61] probbit-persona curiosity (RFC 0016): an optional curiosity block (needs expectation; in the digest) lowers to the engine-owned number input cur_level (box [0, cap], carrying its effects); the level = clip(quiet x max(0, 1 - surprise_recent / quiet_z) + failed x failed_contexts, 0, cap) at 6 decimals, from a fold of the logged lines inside the expectation's canonical memory (EWMA of |z| from the logged pred, per-combo last-K misses, hours since the last stir, the asleep flag), carried by checkpoints (expect.curiosity); stance drives.curiosity and an event-line receipt verify recomputes; Live::stir_due(idle_hours): threshold, rate cap, a quiet class at a slower rate while a declared flag is on; a stir is an event with stir: ; off by default (no input, no key, no line field without the block); tests the_block_is_read_strictly, a_quiet_night_stirs_at_the_declared_rate_and_the_habit_binds, the_drive_replays_resumes_and_restarts_from_a_checkpoint --- probbit-persona/src/curiosity.rs | 244 +++++++++++++++++++++++++++++++ probbit-persona/src/persona.rs | 99 +++++++++++-- probbit-persona/src/strand.rs | 27 +++- 3 files changed, 356 insertions(+), 14 deletions(-) create mode 100644 probbit-persona/src/curiosity.rs diff --git a/probbit-persona/src/curiosity.rs b/probbit-persona/src/curiosity.rs new file mode 100644 index 0000000..d458d93 --- /dev/null +++ b/probbit-persona/src/curiosity.rs @@ -0,0 +1,244 @@ +//! RFC 0016 curiosity (docs/rfcs/0016-curiosity.md): a declared drive fed by the measured surprise, and the self-started idle +//! event (`stir`) it can gate. Mechanism only: the drive's level is arithmetic over two numbers the expectation block (RFC 0009) +//! already measures, and it moves the stance through one engine-owned number input, `cur_level`, as `pace` moves it through its +//! two. Off by default: a persona without the `curiosity` block gives every document, state and strand as before. +//! +//! - `surprise_recent` s: an exponentially weighted average of |z| (the deviation's z, read from the logged prediction as the +//! expectation's normal reads it) with a half-life in events; it starts at `quiet_z`. +//! - `failed_contexts` F: of the contexts the expectation remembers (its input combos), those whose last K visits all missed +//! (|z| > `miss_z` on each). +//! - level = clip(quiet x max(0, 1 - s / quiet_z) + failed x F, 0, cap), at 6 decimals: it rises while nothing new arrives (s +//! low for long) and while contexts keep failing, and falls when a surprising event raises s. +//! +//! The drive's state (s, the per-context miss runs, the hours since the last stir, the last event's asleep flag) is a fold of the +//! logged lines inside the expectation's canonical memory (engine 0.14.0 on), so checkpoints carry it and a resume or a replay +//! from a checkpoint reads it back. `stir_due` answers from that state and the idle hours since the last event. +use super::*; + +/// The engine-owned number input the drive lowers to (reserved only with the block) +pub const CUR_ID: &str = "cur_level"; +/// The input key that marks a self-started event (reserved only with a `stir` block): its value is the stir's class +pub const STIR_KEY: &str = "stir"; + +/// The declared drive +#[derive(Clone, Debug)] +pub struct Curio { pub hl: f64, pub quiet_z: f64, pub miss_z: f64, pub last_k: usize, pub w_quiet: f64, pub w_failed: f64, pub cap: f64, pub stir: Option } +/// The self-start: due when the level is at least `threshold`, at most one per `every_min` minutes; while the last event's +/// `asleep` flag was on, only with `when_asleep` (flag, minutes, class), whose class never speaks +#[derive(Clone, Debug)] +pub struct StirSpec { pub threshold: f64, pub every_min: f64, pub class: String, pub speaks: bool, pub asleep: Option<(String, f64, String)> } +impl StirSpec { + /// The classes an event may carry under `stir`: the awake one, then the asleep one + pub fn classes(&self) -> Vec<&str> { let mut v = vec![self.class.as_str()]; if let Some((_, _, c)) = &self.asleep { if c != &self.class { v.push(c); } } v } +} + +/// The `curiosity` block of a persona whose traits, moods and inputs are `ids`, `moods` and `inputs` -> (the drive, the effects +/// map its input carries). Refused at the field's path (C-R1 .. C-R3 of the RFC). +pub fn spec(cj: &Json, traits: &[String], flags: &[String]) -> R<(Curio, Json)> { + let ck = keys(cj, "curiosity", &["half_life_events", "quiet_z", "miss_z", "last_k", "weights", "cap", "effects", "stir", "comment"], &["effects"])?; + let f = |k: &str, d: f64| num_or(ck, k, d, "curiosity", Some(-1e6), Some(1e6), false); + let (hl, quiet_z, miss_z, cap) = (f("half_life_events", 8.0)?, f("quiet_z", 0.5)?, f("miss_z", 1.5)?, f("cap", 2.0)?); + need(hl >= 1.0, "curiosity.half_life_events", "must be >= 1 (events)")?; + need(quiet_z > 0.0, "curiosity.quiet_z", "must be > 0 (the recent surprise below which nothing is new)")?; + need(miss_z > 0.0, "curiosity.miss_z", "must be > 0 (the |z| above which a visit missed)")?; + need(cap > 0.0, "curiosity.cap", "must be > 0")?; + let last_k = num_or(ck, "last_k", 3.0, "curiosity", Some(1.0), Some(16.0), true)? as usize; + let (w_quiet, w_failed) = match get(ck, "weights").filter(|x| !x.is_null()) { None => (1.0, 0.5), Some(w) => { + let wk = keys(w, "curiosity.weights", &["quiet", "failed"], &[])?; + (num_or(wk, "quiet", 1.0, "curiosity.weights", Some(0.0), Some(50.0), false)?, num_or(wk, "failed", 0.5, "curiosity.weights", Some(0.0), Some(50.0), false)?) } }; + let ek = get(ck, "effects").and_then(Json::as_obj).filter(|o| !o.is_empty()).ok_or_else(|| perr("curiosity.effects", "a mapping trait or mood -> ordinal shift per unit of the level"))?; + for (k, x) in ek { let ep = at("curiosity.effects", k); + need(k != "pursue", &ep, "the curiosity drive never moves pursue")?; need(traits.contains(k), &ep, &format!("unknown trait or mood {k:?}"))?; + num(Some(x), &ep, Some(-50.0), Some(50.0), false)?; } + let stir = match get(ck, "stir").filter(|x| !x.is_null()) { None => None, Some(sj) => { + let sk = keys(sj, "curiosity.stir", &["threshold", "every_minutes", "class", "speaks", "when_asleep"], &["threshold"])?; + let threshold = num(get(sk, "threshold"), "curiosity.stir.threshold", Some(0.0), Some(cap), false)?; + need(threshold > 0.0, "curiosity.stir.threshold", "must be > 0 (a level of 0 never stirs)")?; + let every_min = num_or(sk, "every_minutes", 30.0, "curiosity.stir", Some(1.0), Some(1e6), false)?; + let class = match get(sk, "class") { None => "check".to_string(), x => ident(x, "curiosity.stir.class", "class")? }; + let speaks = match get(sk, "speaks") { None | Some(Json::Null) => true, Some(Json::Bool(b)) => *b, Some(_) => return Err(perr("curiosity.stir.speaks", "true | false")) }; + let asleep = match get(sk, "when_asleep").filter(|x| !x.is_null()) { None => None, Some(aj) => { + let ak = keys(aj, "curiosity.stir.when_asleep", &["flag", "every_minutes", "class"], &["flag"])?; + let fl = ident(get(ak, "flag"), "curiosity.stir.when_asleep.flag", "flag")?; + need(flags.contains(&fl), "curiosity.stir.when_asleep.flag", "a declared flag input (true on the events of the asleep hours)")?; + let m = num_or(ak, "every_minutes", 120.0, "curiosity.stir.when_asleep", Some(1.0), Some(1e6), false)?; + need(m >= every_min, "curiosity.stir.when_asleep.every_minutes", "at least the awake every_minutes (asleep stirs are rarer)")?; + let c = match get(ak, "class") { None => "glance".to_string(), x => ident(x, "curiosity.stir.when_asleep.class", "class")? }; + need(c != class || !speaks, "curiosity.stir.when_asleep.class", "the asleep class never speaks: name another class than a speaking awake one")?; + Some((fl, m, c)) } }; + Some(StirSpec { threshold, every_min, class, speaks, asleep }) } }; + Ok((Curio { hl, quiet_z, miss_z, last_k, w_quiet, w_failed, cap, stir }, Json::Obj(ek.to_vec()))) +} + +/// The drive's fold (inside the expectation's canonical memory) +#[derive(Clone, Debug, Default, PartialEq)] +pub struct CurMem { pub s: f64, pub since: Option, pub asleep: bool, pub ctx: Vec<(String, Vec)> } +impl CurMem { + pub fn new(c: &Curio) -> CurMem { CurMem { s: c.quiet_z, ..Default::default() } } + /// The contexts whose last K visits all missed + pub fn failed(&self, c: &Curio) -> usize { self.ctx.iter().filter(|(_, v)| v.len() == c.last_k && v.iter().all(|m| *m)).count() } + /// The drive's level from this fold + pub fn level(&self, c: &Curio) -> f64 { r6((c.w_quiet * (1.0 - self.s / c.quiet_z).max(0.0) + c.w_failed * self.failed(c) as f64).clamp(0.0, c.cap)) } + /// The stance's `drives.curiosity` (and the line's receipt): the level and its two inputs + pub fn doc(&self, c: &Curio) -> Json { + Json::Obj(vec![("failed_contexts".into(), Json::Num(self.failed(c) as f64)), ("level".into(), Json::Num(self.level(c))), ("surprise_recent".into(), Json::Num(self.s))]) + } + /// One event folded in: `key` its combo, `z` its z (None while cold), `kv` its logged inputs. Before the expectation's normal + /// reads the event (z is the deviation's z). Every number at 6 decimals. + pub fn step(&mut self, c: &Curio, key: &str, z: Option, kv: &[(String, Json)]) { + if let Some(z) = z { let lam = 0.5f64.powf(1.0 / c.hl); self.s = r6(lam * self.s + (1.0 - lam) * r6(z.abs())); + let miss = r6(z.abs()) > c.miss_z; + match self.ctx.binary_search_by(|x| x.0.as_str().cmp(key)) { + Ok(i) => { let v = &mut self.ctx[i].1; v.push(miss); if v.len() > c.last_k { v.remove(0); } } + Err(i) => self.ctx.insert(i, (key.to_string(), vec![miss])) } } + let h = get(kv, "elapsed_hours").and_then(Json::as_f64).unwrap_or(0.0); + self.since = if matches!(get(kv, STIR_KEY), Some(Json::Str(_))) && c.stir.is_some() { Some(0.0) } else { self.since.map(|s| r6(s + h)) }; + self.asleep = c.stir.as_ref().and_then(|st| st.asleep.as_ref()).is_some_and(|(fl, _, _)| matches!(get(kv, fl), Some(Json::Bool(true)))); + } + /// The memory's contexts after the expectation's own prune: a context the expectation dropped is dropped here + pub fn prune(&mut self, keep: &[(String, f64, Json)]) { self.ctx.retain(|(k, _)| keep.iter().any(|c| &c.0 == k)); } + /// As a checkpoint line's `expect.curiosity`: {asleep, ctx: [[key, [miss, ...]], ...] in key order, s, since} + pub fn to_json(&self) -> Json { + Json::Obj(vec![("asleep".into(), Json::Bool(self.asleep)), + ("ctx".into(), Json::Arr(self.ctx.iter().map(|(k, v)| Json::Arr(vec![Json::Str(k.clone()), Json::Arr(v.iter().map(|m| Json::Bool(*m)).collect())])).collect())), + ("s".into(), Json::Num(self.s)), ("since".into(), self.since.map_or(Json::Null, Json::Num))]) + } + /// A checkpoint line's `expect.curiosity` -> the fold; Err(what is wrong) + pub fn read(c: &Curio, j: &Json) -> Result { + let kv = j.as_obj().filter(|kv| kv.len() == 4).ok_or("curiosity: an object {asleep, ctx, s, since}")?; + let f = |k: &str| get(kv, k).ok_or(format!("curiosity: no {k}")); + let s = f("s")?.as_f64().filter(|x| x.is_finite() && *x >= 0.0 && r6(*x) == *x).ok_or("curiosity.s: a number >= 0 at 6 decimals")?; + let since = match f("since")? { Json::Null => None, Json::Num(x) if x.is_finite() && *x >= 0.0 && r6(*x) == *x => Some(*x), _ => return Err("curiosity.since: null or hours >= 0 at 6 decimals".into()) }; + let Json::Bool(asleep) = f("asleep")? else { return Err("curiosity.asleep: true or false".into()) }; + let mut ctx: Vec<(String, Vec)> = vec![]; + for x in f("ctx")?.as_arr().ok_or("curiosity.ctx: a list")? { + let Some([Json::Str(k), Json::Arr(m)]) = x.as_arr() else { return Err("curiosity.ctx: each [key, [miss, ...]]".into()) }; + if ctx.last().is_some_and(|l| l.0 >= *k) { return Err("curiosity.ctx: in key order, each key once".into()); } + let m: Option> = m.iter().map(|b| if let Json::Bool(b) = b { Some(*b) } else { None }).collect(); + let m = m.filter(|m| !m.is_empty() && m.len() <= c.last_k).ok_or("curiosity.ctx: 1 to last_k misses (true / false)")?; + ctx.push((k.clone(), m)); } + Ok(CurMem { s, since, asleep: *asleep, ctx }) + } +} + +/// A self-started idle event that is due: its class, whether it may speak, whether the individual was asleep, the level that +/// gated it, and the minutes since the last stir (None: none yet) +#[derive(Clone, Debug, PartialEq)] +pub struct Stir { pub class: String, pub speaks: bool, pub asleep: bool, pub level: f64, pub since_minutes: Option } +impl Stir { + /// The control query's answer and the event a host feeds when it acts on it (`{"stir": class}` plus its own readings) + pub fn to_json(&self) -> Json { + Json::Obj(vec![("asleep".into(), Json::Bool(self.asleep)), ("class".into(), Json::Str(self.class.clone())), ("level".into(), Json::Num(self.level)), + ("since_minutes".into(), self.since_minutes.map_or(Json::Null, |m| Json::Num(r6(m)))), ("speaks".into(), Json::Bool(self.speaks))]) + } +} +/// Is a stir due `idle_hours` after the last event? Exactly from the fold: the level at least the threshold; while asleep only +/// with `when_asleep` (its class, its rate); at most one per the declared minutes since the last stir (none yet: due) +pub fn stir_due(c: &Curio, m: &CurMem, idle_hours: f64) -> Option { + let sp = c.stir.as_ref()?; let level = m.level(c); + if level < sp.threshold { return None; } + let (every, class, speaks) = if m.asleep { let (_, e, cl) = sp.asleep.as_ref()?; (*e, cl.clone(), false) } else { (sp.every_min, sp.class.clone(), sp.speaks) }; + let since = m.since.map(|s| r6((s + idle_hours.max(0.0)) * 60.0)); + if since.is_some_and(|s| s < every) { return None; } + Some(Stir { class, speaks, asleep: m.asleep, level, since_minutes: since }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::strand::{self, Clock, Live}; + fn run(prog: &Json, f: &Flags) -> Json { run_program(prog, f, 1, 100, 0) } + fn g<'a>(j: &'a Json, ks: &[&str]) -> &'a Json { static NULL: Json = Json::Null; ks.iter().try_fold(j, |x, k| x.get(k)).unwrap_or(&NULL) } + const DOC: &str = r#"{"probbit_persona":1,"identity":{"name":"C","version":"1","seed":3}, + "traits":[{"id":"verbosity","levels":["terse","short","full"],"logw":[0.2,0.5,0.1]},{"id":"initiative","levels":["wait","offer","lead"],"logw":[0.3,0.2,0]}], + "moods":[{"id":"valence","levels":["down","even","up"],"inertia":0.5,"half_life_hours":6}], + "inputs":[{"id":"night","kind":"flag","effects":{"verbosity":-0.5}},{"id":"ping","kind":"flag","effects":{"valence":0.6}},{"id":"odd","kind":"number","max":3,"effects":{"valence":-0.8,"verbosity":0.6}}], + "habits":[{"id":"quiet_at_night","when":{"night":true},"then":{"verbosity":["terse"],"initiative":["wait","offer"]}}], + "expectation":{"half_life_events":8,"max_combos":6}CUR}"#; + const CUR: &str = r#","curiosity":{"half_life_events":4,"quiet_z":0.5,"miss_z":1.5,"last_k":2,"weights":{"quiet":1.5,"failed":0.5},"cap":2,"effects":{"initiative":0.8}, + "stir":{"threshold":0.6,"every_minutes":30,"class":"check","when_asleep":{"flag":"night","every_minutes":120,"class":"glance"}}}"#; + fn doc(c: &str) -> Json { json::parse(&DOC.replace("CUR", c)).unwrap() } + fn refused(c: &str) -> String { match build(&doc(c)) { Err(e) => format!("{}: {}", e.path, e.msg), Ok(_) => "ok".into() } } + fn ev(s: &str) -> Json { json::parse(s).unwrap() } + /// A day of varied events, then a dark night of identical sensed events every 10 minutes; the host polls `stir_due` after each + /// event and feeds the stir it is offered -> (the strand's text, the individual, per night tick the level and the stir fed) + fn life(with_checkpoints: bool) -> (String, Box, Vec<(f64, Option)>) { + let d = doc(CUR); let p = build(&d).unwrap(); + let (mut lv, header) = Live::start(p.clone(), &d, init(&p, Some(2), true, &run), Clock::Fixed, "probbit 0.14.0"); + let mut text = format!("{header}\n"); let mut night = vec![]; + let mut feed = |lv: &mut Live, e: &Json, text: &mut String| { let (s, l) = lv.event(e, &run).unwrap(); text.push_str(&l); text.push('\n'); + if with_checkpoints && lv.n % 10 == 0 { text.push_str(&lv.checkpoint(&s)); text.push('\n'); } s }; + for k in 0..24 { let e = match k % 4 { 0 => r#"{"ping":true,"elapsed_hours":0.25}"#.to_string(), 1 => format!(r#"{{"odd":{},"elapsed_hours":0.25}}"#, k % 3 + 1), + 2 => r#"{"elapsed_hours":0.25}"#.to_string(), _ => r#"{"ping":true,"odd":2,"elapsed_hours":0.25}"#.to_string() }; + feed(&mut lv, &ev(&e), &mut text); } + for _ in 0..48 { feed(&mut lv, &ev(r#"{"night":true,"elapsed_hours":0.166667}"#), &mut text); + let lvl = crate::persona::cur_level(&lv.p, lv.expect.as_ref().unwrap()).unwrap(); + let due = lv.stir_due(0.0); + if let Some(s) = &due { let st = feed(&mut lv, &ev(&format!(r#"{{"night":true,"stir":"{}","elapsed_hours":0}}"#, s.class)), &mut text); + assert!(g(&st, &["habits", "active"]).as_arr().unwrap().iter().any(|h| h.as_str() == Some("quiet_at_night")), "{}", canon(&st)); + assert_eq!(g(&st, &["stance", "verbosity", "level"]).as_str(), Some("terse"), "the habit binds on the stir"); + assert_eq!(g(&st, &["drives", "curiosity", "stir"]).as_str(), Some(s.class.as_str())); } + night.push((lvl, due)); } + (text, Box::new(lv), night) + } + #[test] + fn the_block_is_read_strictly() { + assert_eq!(refused(""), "ok"); + assert_eq!(refused(CUR), "ok"); + assert_eq!(match build(&json::parse(&DOC.replace("CUR", CUR).replace(r#","expectation":{"half_life_events":8,"max_combos":6}"#, "")).unwrap()) { Err(e) => format!("{}: {}", e.path, e.msg), Ok(_) => "ok".into() }, + "curiosity: a curiosity block needs an expectation block (the drive reads its measured surprise)"); + assert_eq!(refused(r#","curiosity":{"effects":{"pursue":1}}"#), "curiosity.effects.pursue: the curiosity drive never moves pursue"); + assert_eq!(refused(r#","curiosity":{"effects":{"nope":1}}"#), r#"curiosity.effects.nope: unknown trait or mood "nope""#); + assert_eq!(refused(r#","curiosity":{"effects":{}}"#), "curiosity.effects: a mapping trait or mood -> ordinal shift per unit of the level"); + assert_eq!(refused(r#","curiosity":{"effects":{"initiative":1},"quiet_z":0}"#), "curiosity.quiet_z: must be > 0 (the recent surprise below which nothing is new)"); + assert_eq!(refused(r#","curiosity":{"effects":{"initiative":1},"stir":{"threshold":0.5,"when_asleep":{"flag":"ping2"}}}"#), + "curiosity.stir.when_asleep.flag: a declared flag input (true on the events of the asleep hours)"); + assert_eq!(refused(r#","curiosity":{"effects":{"initiative":1},"stir":{"threshold":0.5,"every_minutes":60,"when_asleep":{"flag":"night","every_minutes":30}}}"#), + "curiosity.stir.when_asleep.every_minutes: at least the awake every_minutes (asleep stirs are rarer)"); + assert_eq!(refused(r#","curiosity":{"effects":{"initiative":1},"stir":{"threshold":3}}"#).split(':').next(), Some("curiosity.stir.threshold")); + let d = DOC.replace("CUR", CUR).replace(r#"{"id":"ping","kind":"flag""#, r#"{"id":"cur_level","kind":"flag""#).replace(r#""ping":true"#, r#""cur_level":true"#); + assert_eq!(build(&json::parse(&d).unwrap()).err().map(|e| e.path), Some("inputs[1].id".to_string())); + // off by default: without the block no cur_level, no drives key, and the digest is another + let (a, b) = (build(&doc("")).unwrap(), build(&doc(CUR)).unwrap()); assert_ne!(a.digest, b.digest); + let (sa, _) = turn(&a, &init(&a, Some(1), true, &run), &ev(r#"{"ping":true}"#), false, &run, false).unwrap(); + assert!(sa.get("drives").is_none() && g(&sa, &["inputs", "cur_level"]).is_null(), "{}", canon(&sa)); + let (sb, _) = turn(&b, &init(&b, Some(1), true, &run), &ev(r#"{"ping":true}"#), false, &run, false).unwrap(); + assert!(g(&sb, &["inputs", "cur_level"]).is_null(), "cur_level leaves inputs: {}", canon(&sb)); + assert_eq!(turn(&b, &init(&b, Some(1), true, &run), &ev(r#"{"cur_level":1}"#), false, &run, false).err().map(|e| e.path), Some("inputs.cur_level".to_string())); + // the expectation block outside the digest still: with and without it the curiosity persona's digest... is another persona + let d = doc(CUR); let p = build(&d).unwrap(); let (mut lv, _) = Live::start(p.clone(), &d, init(&p, Some(2), true, &run), Clock::Fixed, "probbit 0.14.0"); + assert_eq!(lv.event(&ev(r#"{"stir":"hum"}"#), &run).err().map(|e| format!("{}: {}", e.path, e.msg)), Some("event.stir: a declared stir class: check | glance".to_string())); + } + #[test] + fn a_quiet_night_stirs_at_the_declared_rate_and_the_habit_binds() { + let (_, lv, night) = life(false); + let stirs: Vec<(usize, &Stir)> = night.iter().enumerate().filter_map(|(i, (_, s))| s.as_ref().map(|s| (i, s))).collect(); + eprintln!("R16 night: levels {:?}", night.iter().map(|x| x.0).collect::>()); + eprintln!("R16 night: stirs at ticks {:?}", stirs.iter().map(|x| x.0).collect::>()); + assert!(night[0].0 < 0.6, "the night starts below the threshold: {}", night[0].0); + assert!(stirs.len() >= 2, "the quiet night raises the drive past the threshold"); + for w in stirs.windows(2) { assert!((w[1].0 - w[0].0) * 10 >= 120, "asleep: at most one stir per 120 minutes: {:?}", stirs); } + assert!(stirs.iter().all(|(_, s)| s.class == "glance" && !s.speaks && s.asleep)); + // awake, the same level stirs at the awake class and rate + assert!(lv.stir_due(10.0).is_some_and(|s| s.asleep), "the last event was a night event"); + } + #[test] + fn the_drive_replays_resumes_and_restarts_from_a_checkpoint() { + for cps in [false, true] { + let (text, lv, _) = life(cps); + let v = strand::verify(&text, &run).unwrap_or_else(|(i, w)| panic!("line {i}: {w}")); + assert_eq!(v.get("events").and_then(Json::as_f64), Some(lv.n as f64), "{}", canon(&v)); + let ls = strand::lines(&text); let (mut r, s0) = strand::Replay::open_lines(&ls).unwrap(); + for i in s0..ls.len() { r.step_at(&ls, i, &run).unwrap(); } + for h in [0.0, 0.5, 3.0] { assert_eq!(r.live.stir_due(h), lv.stir_due(h)); } + assert_eq!(r.live.expect.as_ref().unwrap().cur, lv.expect.as_ref().unwrap().cur); + let rs = strand::resume(&text, &lv.p, lv.st.clone(), Clock::Fixed).unwrap(); + assert_eq!((rs.n, rs.head(), rs.expect.as_ref().unwrap().cur.clone()), (lv.n, lv.head(), lv.expect.as_ref().unwrap().cur.clone())); + if cps { strand::verify_from_checkpoint(&text, &run).unwrap_or_else(|(i, w)| panic!("from checkpoint: line {i}: {w}")); + assert!(text.contains(r#""curiosity":{"asleep":"#), "the checkpoint carries the fold"); } + // an edited receipt diverges at its line + let i = text.find(r#""surprise_recent":"#).unwrap(); let mut t2 = text.clone(); t2.replace_range(i..i + 18, r#""surprise_recent":9"#); + assert!(strand::verify(&t2, &run).is_err()); + } + } +} diff --git a/probbit-persona/src/persona.rs b/probbit-persona/src/persona.rs index 1d6bdf1..e53ddd5 100644 --- a/probbit-persona/src/persona.rs +++ b/probbit-persona/src/persona.rs @@ -23,6 +23,10 @@ pub mod hostlint; /// RFC 0014 integrity: the drift from the self the persona declares (off without the `identity` self keys) #[path = "integrity.rs"] pub mod integrity; +/// RFC 0016 curiosity: a declared drive fed by the measured surprise, and the self-start it gates (off without the block) +#[path = "curiosity.rs"] +pub mod curiosity; +pub use curiosity::{Curio, CUR_ID}; 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"]; @@ -212,7 +216,9 @@ pub struct Persona { pub name: String, pub version: String, pub seed: u64, pub d /// §2.18: the `display.glyphs` block (outside the digest; effects {}) pub glyphs: Option, /// RFC 0014: the `identity` block's self keys (outside the digest; effects {}) - pub ident: Option } + pub ident: Option, + /// RFC 0016: the `curiosity` drive (in the digest: its level moves the stance through `cur_level`) + pub cur: Option } impl Persona { /// `line.why_past` (§2.11): `why` names what the learned weights came from; default on with a senses block, off without fn why_past(&self) -> bool { self.why_past.unwrap_or(self.senses.is_some()) } @@ -234,7 +240,7 @@ impl Persona { /// `load_level` with a load block (RFC 0002 part 2): the engine sets it from L fn is_load(&self, id: &str) -> bool { self.load.is_some() && id == LOAD_ID } /// An input the engine sets from its own state (pace's two, load's one): never the host's - fn is_owned(&self, id: &str) -> bool { self.is_pace(id) || self.is_load(id) } + fn is_owned(&self, id: &str) -> bool { self.is_pace(id) || self.is_load(id) || (self.cur.is_some() && id == CUR_ID) } 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") } @@ -362,6 +368,9 @@ pub fn build_boxed(doc: &Json) -> R> { // persona with a recorded life leaves every state, strand and turn byte for byte the same (amendment A1) if let Some(b) = doc.get("bound") { let mut d = doc.clone(); if let Json::Obj(v) = &mut d { v.retain(|(k, _)| k != "bound"); } let p = build_boxed(&d)?; bound::spec(b, &p)?; return Ok(p); } + // RFC 0016: `curiosity` lowers to the engine-owned number input `cur_level` (box [0, cap], carrying the block's effects, as + // pace lowers to its two) and enters the digest; it needs an expectation block (the drive reads its measured surprise) + if let Some(cj) = doc.get("curiosity").filter(|c| !c.is_null()) { return build_curiosity(doc, cj); } // RFC 0009: `expectation` and `engine.trajectory` move nothing (effects {}): they are read against the persona built without // them and stay out of the digest, so adding either to a persona with a recorded life leaves every state and stance digest as // it was (an `engine` mapping left empty is dropped with them) @@ -378,6 +387,34 @@ pub fn build_boxed(doc: &Json) -> R> { return Ok(p); } build_base(doc) } +/// RFC 0016: the persona with a `curiosity` block (C-R1 .. C-R3 refuse at the field's path): built without the block and with its +/// input `cur_level` after the declared inputs; the digest is the document's as given (without the keys outside the digest) +#[inline(never)] +fn build_curiosity(doc: &Json, cj: &Json) -> R> { + need(doc.get("expectation").is_some_and(|e| !e.is_null()), "curiosity", "a curiosity block needs an expectation block (the drive reads its measured surprise)")?; + let ids_of = |k: &str| -> Vec { match doc.get(k) { Some(Json::Arr(a)) => a.iter().filter_map(|t| t.get("id").and_then(Json::as_str).map(str::to_string)).collect(), _ => vec![] } }; + let mut ids = ids_of("traits"); ids.extend(ids_of("moods")); + let ins: Vec = match doc.get("inputs") { Some(Json::Arr(a)) => a.clone(), _ => vec![] }; + let flags: Vec = ins.iter().filter(|x| x.get("kind").and_then(Json::as_str) == Some("flag")).filter_map(|x| x.get("id").and_then(Json::as_str).map(str::to_string)).collect(); + let (cu, eff) = curiosity::spec(cj, &ids, &flags)?; + for (j, x) in ins.iter().enumerate() { if let Some(id) = x.get("id").and_then(Json::as_str) { + need(id != CUR_ID && id != curiosity::STIR_KEY, &format!("inputs[{j}].id"), "reserved for the curiosity block (cur_level, stir)")?; } } + if let Some(Json::Arr(hs)) = doc.get("habits") { for (i, h) in hs.iter().enumerate() { if let Some(w) = h.get("when").and_then(Json::as_obj) { for (k, _) in w { + need(k != CUR_ID && k != curiosity::STIR_KEY, &format!("habits[{i}].when.{k}"), "reserved for the curiosity block")?; } } } } + let mut d = doc.clone(); let mut dg = doc.clone(); + if let Json::Obj(v) = &mut d { v.retain(|(k, _)| k != "curiosity"); + let x = Json::Obj(vec![("id".into(), Json::Str(CUR_ID.into())), ("kind".into(), Json::Str("number".into())), ("max".into(), Json::Num(cu.cap)), + ("say".into(), Json::Str("curiosity".into())), ("effects".into(), eff)]); + let mut ins = ins; ins.push(x); + match v.iter_mut().find(|(k, _)| k == "inputs") { Some(e) => e.1 = Json::Arr(ins), None => v.push(("inputs".into(), Json::Arr(ins))) } } + // the digest's document: as given, without `expectation` and `engine.trajectory` (outside every digest) + if let Json::Obj(v) = &mut dg { v.retain(|(k, _)| k != "expectation"); + if let Some(i) = v.iter().position(|(k, _)| k == "engine") { + if let Json::Obj(e) = &mut v[i].1 { e.retain(|(k, _)| k != "trajectory"); } + if v[i].1.as_obj().is_some_and(|e| e.is_empty()) { v.remove(i); } } } + let mut p = build_boxed(&d)?; p.digest = sha(&dg); p.cur = Some(cu); + Ok(p) +} /// The persona without the layers `build_boxed` peels off: the top-level keys, then the lowered blocks or the base reader #[inline(never)] fn build_base(doc: &Json) -> R> { @@ -513,7 +550,7 @@ fn build_base(doc: &Json) -> R> { 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 learning = match get(kv, "learning").filter(|l| !l.is_null()) { None => None, Some(l) => Some(learning_spec(l, &vars, &inputs)?) }; let reward = match get(kv, "reward_from").filter(|x| !x.is_null()) { None => None, Some(rf) => Some(reward_spec(rf, &learning.as_ref().map_or(vec![], |l| l.from.clone()), &inputs)?) }; - let p = Box::new(Persona { name, version, seed, digest, vars, steps, slots, say_order, prefer, couplings, inputs, history, habits, eng, max_tokens, order, prefix, learning, drives: None, reward, senses: None, why_past, surprise: None, pace: None, disp: None, rest_b: None, load: None, block, expect: None, traj: None, glyphs: None, ident: None }); + let p = Box::new(Persona { name, version, seed, digest, vars, steps, slots, say_order, prefer, couplings, inputs, history, habits, eng, max_tokens, order, prefix, learning, drives: None, reward, senses: None, why_past, surprise: None, pace: None, disp: None, rest_b: None, load: None, block, expect: None, traj: None, glyphs: None, ident: None, cur: None }); // 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()); } @@ -1450,9 +1487,14 @@ fn prep(p: &Persona, st: &State, raw: &[(String, Json)], src: Option<&Json>, che fn prep_n(p: &Persona, st: &State, raw: &[(String, Json)], src: Option<&Json>, check: bool, narrow: bool) -> R<(State, Vec<(String, Json)>)> { // RFC 0002 part 2: the engine sets load_level from L (the host never sends it) if p.load.is_some() { need(get(raw, LOAD_ID).is_none(), "inputs.load_level", "reserved for the load block (the engine sets it from the load)")?; } + // RFC 0016: the engine sets cur_level from the drive's fold (the host never sends it) + if p.cur.is_some() { need(get(raw, CUR_ID).is_none(), "inputs.cur_level", "reserved for the curiosity block (the engine sets it from the drive)")?; } let (mut s2, mut host) = prep_body(p, st, raw, src, check, narrow)?; pressure_step(p, &mut s2, raw)?; load_step(p, st, &mut s2, raw, &mut host)?; + // RFC 0016: the level the live writer set from the fold before this event (0 without a memory: `persona turn`); a stir's + // marker is the line's, not the compiler's + if p.cur.is_some() { host.retain(|(k, _)| k != curiosity::STIR_KEY); host.push((CUR_ID.into(), Json::Num(st.cur.unwrap_or(0.0)))); } Ok((s2, host)) } /// Does this event carry feedback (RFC 0002 part 2: a rest line never shares an event with a learning step)? A learning flag on, @@ -1732,6 +1774,8 @@ fn stance_body(j: &mut Json, p: &Persona, st: &State) { // RFC 0002 part 2: load_level leaves `inputs`; `load: {l}` = L after this event if let Some(l) = st.load_l { if let Some((_, Json::Obj(ins))) = v.iter_mut().find(|(k, _)| k == "inputs") { ins.retain(|(k, _)| k != LOAD_ID); } v.push(("load".into(), Json::Obj(vec![("l".into(), Json::Num(l))]))); } + // RFC 0016: cur_level leaves `inputs` (the live writer adds `drives.curiosity` with the level and its two inputs) + if p.cur.is_some() { if let Some((_, Json::Obj(ins))) = v.iter_mut().find(|(k, _)| k == "inputs") { ins.retain(|(k, _)| k != CUR_ID); } } if let Some(gs) = &st.gate { v.push(("surprise".into(), Json::Obj(gs.iter().filter(|(_, g)| g.g > 1.0).map(|(t, g)| (t.clone(), Json::Num(g.g))).collect()))); } } /// The pace level of an average (RFC 0002 Design C): slow below `levels[0]`, brisk above `levels[1]`, steady between @@ -2224,7 +2268,10 @@ fn values_of(p: &Persona) -> Vec { 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>, learned: Table, credit: Table, drives: Option, senses: Option, learned_from: Option>, open: Vec, prev_stance: Option, control_closed: Vec, elig: f64, pub digest: String, - gate: Option>, rbar: Option, disp: Vec<(String, f64)>, pressure: Option<(f64, bool)>, load_l: Option } + gate: Option>, rbar: Option, disp: Vec<(String, f64)>, pressure: Option<(f64, bool)>, load_l: Option, + /// RFC 0016: the curiosity drive's level the next turn reads (set from the expectation's memory before each live event; not + /// part of the state's JSON or digest: the memory is the fold of the logged lines, so a replay sets it the same way) + pub cur: Option } /// A decision awaiting its outcome (§2.8, `open_credit`): its turn, its credit (onehot(level) − odds per learned trait it released) /// and its stance's digest #[derive(Clone)] @@ -2325,7 +2372,7 @@ impl State { history: self.history.clone(), prev: self.prev.clone(), agenda: self.agenda.as_ref().filter(|_| w(true)).cloned(), rest: self.rest.as_ref().filter(|_| w(true)).cloned(), learned: self.learned.clone(), credit: self.credit.clone(), drives: if stepped.1 { None } else { self.drives.clone() }, senses: if stepped.0 { None } else { self.senses.clone() }, learned_from: self.learned_from.as_ref().filter(|_| w(true)).cloned(), open: self.open.clone(), prev_stance: self.prev_stance.clone(), control_closed: self.control_closed.clone(), - elig: self.elig, digest: if w(true) { self.digest.clone() } else { String::new() }, gate: self.gate.clone(), rbar: self.rbar, disp: self.disp.clone(), pressure: self.pressure, load_l: self.load_l } + elig: self.elig, digest: if w(true) { self.digest.clone() } else { String::new() }, gate: self.gate.clone(), rbar: self.rbar, disp: self.disp.clone(), pressure: self.pressure, load_l: self.load_l, cur: self.cur } } fn history_of(&self, of: &str) -> &[bool] { self.history.iter().find(|(k, _)| k == of).map_or(&[], |(_, v)| v) } /// The state document without its digest @@ -2482,7 +2529,7 @@ impl State { _ => return Err(perr("state.prev_stance", "the previous stance's digest (sha256:<64 hex>); null before any turn")) }; let ca = get(kv, "control_closed").and_then(Json::as_arr).filter(|a| a.len() <= c.max_open).ok_or_else(|| perr("state.control_closed", format!("a list of at most {} turns", c.max_open)))?; for (i, x) in ca.iter().enumerate() { let n = turn_at(x, &ix("state.control_closed", i), control_closed.last().copied())?; control_closed.push(n); } } - Ok(State { seed, turn, persona, genes: g.clone(), mood, history, prev, agenda, rest, learned, credit, drives, senses, learned_from, open, prev_stance, control_closed, elig: 1.0, gate, rbar, disp, pressure, load_l, + Ok(State { seed, turn, persona, genes: g.clone(), mood, history, prev, agenda, rest, learned, credit, drives, senses, learned_from, open, prev_stance, control_closed, elig: 1.0, gate, rbar, disp, pressure, load_l, cur: None, digest: get(kv, "digest").and_then(Json::as_str).unwrap_or("").to_string() }) } } @@ -3099,7 +3146,7 @@ pub fn init(p: &Persona, seed: Option, rest: bool, eng: Engine) -> State { prev: p.vars.iter().map(|v| (v.id.clone(), v.fallback.clone())).collect(), agenda: (!p.steps.is_empty()).then(|| p.steps.clone()), rest: None, learned: zeros(p), credit: zeros(p), drives: p.drives.as_ref().map(|d| DState::zero(p, d, seed)), senses: p.senses.as_ref().map(|sn| SState::zero(&p.name, sn, seed)), learned_from: learned_from0(p), open: vec![], prev_stance: None, control_closed: vec![], elig: 1.0, digest: String::new(), - gate: gate0(p), rbar: p.pace.as_ref().map(|_| 0.0), disp: disp_genes(p, seed), pressure: p.rest_b.as_ref().map(|_| (0.0, false)), load_l: p.load.as_ref().map(|_| 0.0) }; + gate: gate0(p), rbar: p.pace.as_ref().map(|_| 0.0), disp: disp_genes(p, seed), pressure: p.rest_b.as_ref().map(|_| (0.0, false)), load_l: p.load.as_ref().map(|_| 0.0), cur: None }; 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) { @@ -4003,7 +4050,9 @@ fn traj_spec(x: &Json) -> R { /// memory is then a small exact object a checkpoint line carries (`to_json`, `read`), the same bytes on every libm, and a fold /// restarted from it equals the fold from the strand's header. #[derive(Clone, Debug, Default)] -pub struct ExpectMem { combos: Vec<(String, f64, Json)>, warm: bool, mean: f64, var: f64, n: u64, rest: f64, canon: bool } +pub struct ExpectMem { combos: Vec<(String, f64, Json)>, warm: bool, mean: f64, var: f64, n: u64, rest: f64, canon: bool, + /// RFC 0016: the curiosity drive's fold (canonical memory with a `curiosity` block only; None before the first event) + pub cur: Option } const DEV_PRIOR_VAR: f64 = 0.04; const DEV_VAR_FLOOR: f64 = 1e-4; /// z at or above this registers the deviation (the RFC 0001 sense threshold) @@ -4018,14 +4067,19 @@ impl ExpectMem { /// a combo is a function of its inputs (`combo_key`), so it is not written pub fn to_json(&self) -> Json { let mut c: Vec<&(String, f64, Json)> = self.combos.iter().collect(); c.sort_by(|a, b| a.0.cmp(&b.0)); - Json::Obj(vec![("combos".into(), Json::Arr(c.iter().map(|x| Json::Arr(vec![Json::Num(x.1), x.2.clone()])).collect())), ("mean".into(), Json::Num(self.mean)), - ("n".into(), Json::Num(self.n as f64)), ("rest".into(), Json::Num(self.rest)), ("var".into(), Json::Num(self.var)), ("warm".into(), Json::Bool(self.warm))]) + let j = Json::Obj(vec![("combos".into(), Json::Arr(c.iter().map(|x| Json::Arr(vec![Json::Num(x.1), x.2.clone()])).collect())), ("mean".into(), Json::Num(self.mean)), + ("n".into(), Json::Num(self.n as f64)), ("rest".into(), Json::Num(self.rest)), ("var".into(), Json::Num(self.var)), ("warm".into(), Json::Bool(self.warm))]); + // RFC 0016: the drive's fold rides with the memory (only with a curiosity block) + let mut j = j; if let (Some(c), Json::Obj(v)) = (&self.cur, &mut j) { v.insert(1, ("curiosity".into(), c.to_json())); } + j } /// A checkpoint line's `expect` for persona `p` -> the canonical memory; Err(what is wrong). It must be what the canonical fold /// can hold: combos in key order, each count > 0 at 6 decimals, distinct keys, at most `max_combos - 1` named ones pub fn read(p: &Persona, j: &Json) -> Result { let ex = p.expect.as_ref().ok_or("the persona has no expectation block")?; - let kv = j.as_obj().filter(|kv| kv.len() == 6).ok_or("an object {combos, mean, n, rest, var, warm}")?; + // RFC 0016: with a curiosity block the memory has a seventh key, `curiosity` (the drive's fold) + let want = if p.cur.is_some() { 7 } else { 6 }; + let kv = j.as_obj().filter(|kv| kv.len() == want).ok_or(if want == 7 { "an object {combos, curiosity, mean, n, rest, var, warm}" } else { "an object {combos, mean, n, rest, var, warm}" })?; let f = |k: &str| kv.iter().find(|(x, _)| x == k).map(|(_, v)| v).ok_or(format!("no {k}")); let r6n = |k: &str| -> Result { f(k)?.as_f64().filter(|x| x.is_finite() && *x >= 0.0 && r6(*x) == *x).ok_or(format!("{k}: a number >= 0 at 6 decimals")) }; let mean = f("mean")?.as_f64().filter(|x| x.is_finite() && r6(*x) == *x).ok_or("mean: a number at 6 decimals")?; @@ -4040,7 +4094,8 @@ impl ExpectMem { if combos.last().is_some_and(|l| l.0 >= key) { return Err("combos: in key order, each key once".into()); } combos.push((key, cnt, rep.clone())); } if combos.iter().filter(|c| c.0 != "{}").count() > ex.max_combos - 1 { return Err(format!("combos: at most {} named", ex.max_combos - 1)); } - Ok(ExpectMem { combos, warm: *warm, mean, var, n, rest, canon: true }) + let cur = match &p.cur { Some(c) => Some(curiosity::CurMem::read(c, f("curiosity")?)?), None => None }; + Ok(ExpectMem { combos, warm: *warm, mean, var, n, rest, canon: true, cur }) } } /// One event's deviation from the engine's own expectation (nats): `s_self` = sum over the non-twin variables of KL(A_i || E_i), @@ -4548,6 +4603,11 @@ pub fn expect_step(p: &Persona, mem: &mut ExpectMem, raw: &Json, logged_pred: Op for c in &mut mem.combos { c.1 *= d; } match mem.combos.iter_mut().find(|c| c.0 == key) { Some(c) => { c.1 += 1.0; c.2 = rep; } None => mem.combos.push((key.clone(), 1.0, rep)) } if key != "{}" { mem.warm = true; } + // RFC 0016 (canonical memory): the drive's fold reads the event's z as the deviation computed it (the logged prediction + // against the normal before this event), before the normal moves + if let (Some(c), true) = (p.cur.as_ref(), mem.canon) { + let z = logged_pred.map(|x| ((1.0 + x).ln() - mem.mean) / (mem.var + DEV_VAR_FLOOR).sqrt()); + mem.cur.get_or_insert_with(|| curiosity::CurMem::new(c)).step(c, &key, z, kv); } if let Some(x) = logged_pred { let (x, a) = ((1.0 + x).ln(), 1.0 - d); let dx = x - mem.mean; mem.mean += a * dx; mem.var = (1.0 - a) * (mem.var + a * dx * dx); mem.n += 1; } if mem.canon { @@ -4559,9 +4619,24 @@ pub fn expect_step(p: &Persona, mem: &mut ExpectMem, raw: &Json, logged_pred: Op let Some(i) = (0..mem.combos.len()).filter(|&i| mem.combos[i].0 != "{}").min_by(|&a, &b| { let (x, y) = (&mem.combos[a], &mem.combos[b]); x.1.partial_cmp(&y.1).unwrap_or(std::cmp::Ordering::Equal).then(y.0.cmp(&x.0)) }) else { break }; let c = mem.combos.remove(i); mem.rest = r6(mem.rest + c.1); } + if let Some(cm) = mem.cur.as_mut() { cm.prune(&mem.combos); } mem.mean = r6(mem.mean); mem.var = r6(mem.var); } } +/// RFC 0016: the curiosity drive's level the next event's turn reads, from the expectation's memory (None without the block or +/// before the canonical fold, engine 0.14.0); a memory with no event yet reads the fold's start (level 0 from quiet) +pub fn cur_level(p: &Persona, mem: &ExpectMem) -> Option { + let c = p.cur.as_ref().filter(|_| mem.canon)?; Some(mem.cur.as_ref().map_or_else(|| curiosity::CurMem::new(c).level(c), |m| m.level(c))) +} +/// RFC 0016: the stance's `drives.curiosity` (the level and its two inputs, as the turn read them) from the memory before the event +pub fn cur_doc(p: &Persona, mem: &ExpectMem) -> Option { + let c = p.cur.as_ref().filter(|_| mem.canon)?; Some(mem.cur.as_ref().map_or_else(|| curiosity::CurMem::new(c).doc(c), |m| m.doc(c))) +} +/// RFC 0016: is a self-started idle event due `idle_hours` after the last event (`curiosity::stir_due` on the memory's fold) +pub fn stir_due(p: &Persona, mem: &ExpectMem, idle_hours: f64) -> Option { + let c = p.cur.as_ref().filter(|_| mem.canon)?; let start; let m = match mem.cur.as_ref() { Some(m) => m, None => { start = curiosity::CurMem::new(c); &start } }; + curiosity::stir_due(c, m, idle_hours) +} /// `j` with every object's keys sorted (by code point) at every level: the value canonical JSON writes, as a value fn key_sorted(j: &Json) -> Json { match j { Json::Obj(v) => { let mut o: Vec<(String, Json)> = v.iter().map(|(k, x)| (k.clone(), key_sorted(x))).collect(); o.sort_by(|a, b| a.0.cmp(&b.0)); Json::Obj(o) } diff --git a/probbit-persona/src/strand.rs b/probbit-persona/src/strand.rs index 80758d1..18d0edb 100644 --- a/probbit-persona/src/strand.rs +++ b/probbit-persona/src/strand.rs @@ -142,6 +142,10 @@ impl Live { crate::trace::begin(); if self.status != Status::Active { return Err(refusal(self.status)); } let Json::Obj(kv) = ev else { return Err(perr("event", "must be a JSON object of inputs")) }; + // RFC 0016: a self-started event carries `stir: `, one of the classes the persona's stir block declares + let stir = match (self.p.cur.as_ref().and_then(|c| c.stir.as_ref()), ev.get(persona::curiosity::STIR_KEY)) { + (Some(sp), Some(x)) => { let cl = x.as_str().filter(|c| sp.classes().contains(c)).ok_or_else(|| perr("event.stir", format!("a declared stir class: {}", sp.classes().join(" | "))))?; Some(cl.to_string()) } + _ => None }; let mut kv: Vec<(String, Json)> = kv.iter().filter(|(k, _)| k != "elapsed_hours").cloned().collect(); let given = ev.get("elapsed_hours"); let (elapsed, now) = match &self.clock { @@ -150,6 +154,9 @@ impl Live { Clock::Fixed => { let e = match given { None => 0.0, Some(Json::Num(x)) if x.is_finite() && *x >= 0.0 => *x, _ => return Err(perr("event.elapsed_hours", "a number >= 0")) }; (q6(e), self.last) } }; kv.push(("elapsed_hours".into(), Json::Num(elapsed))); let inputs = Json::Obj(kv); + // RFC 0016: the drive's level the turn reads (and every member of the expectation with it), from the fold before the event + let cdoc = self.expect.as_ref().and_then(|m| persona::cur_doc(&self.p, m)); + self.st.cur = self.expect.as_ref().and_then(|m| persona::cur_level(&self.p, m)); enter(Phase::Expect); // RFC 0009: the deviation and the trajectory are read from the state before the event (effects {}: the turn is as without // them). The trajectory before the turn (nothing of the turn held while it solves). With the cache reusing the turn's own @@ -164,6 +171,12 @@ impl Live { None => (None, None) } }; let (mut stance, ns, at) = persona::turn_at(&self.p, &self.st, &inputs, eng, want)?; let sd = persona::stance_sha(&stance); + // RFC 0016: `drives.curiosity` = the level the turn read and its two inputs (and the stir's class on a self-started event); + // outside the stance digest, as `deviation` (the line's receipt carries it and `verify` recomputes it) + let cdoc = cdoc.map(|mut d| { if let (Some(cl), Json::Obj(v)) = (&stir, &mut d) { v.push(("stir".into(), Json::Str(cl.clone()))); } d }); + if let (Some(cd), Json::Obj(v)) = (&cdoc, &mut stance) { match v.iter_mut().find(|(k, _)| k == "drives") { + Some((_, Json::Obj(dv))) => dv.push(("curiosity".into(), cd.clone())), + _ => v.push(("drives".into(), Json::Obj(vec![("curiosity".into(), cd.clone())]))) } } enter(Phase::Expect); let cache = &mut self.expect_cache; let dev = if want { self.expect.as_ref().and_then(|m| persona::deviation_post(&self.p, &self.st, &inputs, m, eng, cache, held, at)) } else { early }; @@ -181,6 +194,7 @@ impl Live { let mut lj = vec![("n".into(), Json::Num((self.n + 1) as f64)), ("inputs".into(), inputs.clone()), ("stance".into(), Json::Str(sd)), ("state".into(), Json::Str(ns.digest.clone())), ("prev".into(), Json::Str(prev))]; if let Some(d) = &dev { lj.push(("deviation".into(), d.receipt())); } + if let Some(cd) = &cdoc { lj.push(("curiosity".into(), cd.clone())); } if let Some(g) = stance.get("glyph") { lj.push(("glyph".into(), g.clone())); } if let Some(ig) = &integ { lj.push(("integrity".into(), ig.doc.clone())); } let line = persona::canon(&Json::Obj(lj)); @@ -215,6 +229,13 @@ impl Live { } /// The sha256 of the strand's last line (the header's before any event) pub fn head(&self) -> &str { &self.prev } + /// RFC 0016: is a self-started idle event due `idle_hours` after the last event? Exactly from the drive's fold and the idle + /// hours: None without a stir block, below the threshold, inside the rate cap, or asleep without `when_asleep`. A host that + /// acts on it feeds `{"stir": class}` with its own readings as an event; a stir it ignores leaves no line. + pub fn stir_due(&self, idle_hours: f64) -> Option { + if self.status != Status::Active { return None; } + persona::stir_due(&self.p, self.expect.as_ref()?, idle_hours) + } } /// The header piece size `start_boxed` collects with (any size gives the same bytes) pub const HEADER_CHUNK: usize = 1024; @@ -307,7 +328,7 @@ pub fn segment_header(lv: &Live, doc: &Json, engine: &str, index: u64, previous: /// this engine does not know (a line without `control` or `checkpoint` is read as an event line, so `{"prev": ..., "nap": ...}` /// gives `nap`) or a field it does not know on a known one fn unknown_key(j: &Json) -> Option<&str> { - let known: &[&str] = match kind(j) { Kind::Event => &["deviation", "glyph", "inputs", "integrity", "n", "prev", "stance", "state"], Kind::Control => &["at", "by", "control", "prev", "reason"], + let known: &[&str] = match kind(j) { Kind::Event => &["curiosity", "deviation", "glyph", "inputs", "integrity", "n", "prev", "stance", "state"], Kind::Control => &["at", "by", "control", "prev", "reason"], Kind::Checkpoint => &["checkpoint", "expect", "prev", "stance", "state"], Kind::Rest => &["prev", "rest", "state"], Kind::Reflection => &["prev", "reflection"] }; j.as_obj()?.iter().map(|(k, _)| k.as_str()).find(|k| !known.contains(k)) } @@ -929,6 +950,8 @@ fn resume_walk>(q: Box, h: &Json, head: &str, cp: Option< let logged = j.get("state").and_then(Json::as_str).unwrap_or(""); match cur.as_mut() { Some(s) => { let t0 = crate::trace::Stopwatch::start(); + // RFC 0016: the drive's level from the fold before this line, as the live writer set it + s.cur = mem.as_ref().and_then(|m| persona::cur_level(&q, m)); *s = apply_line(&q, s, &j, l, rest.as_deref(), eng).map_err(|why| err(at, why))?; tr.applied += 1; tr.applied_us += t0.us(); } None => if rec.as_ref().is_some_and(|s| s.digest == logged) { cur = rec.take(); } } @@ -989,7 +1012,7 @@ fn line_check(j: &Json, l: &str, inputs: Json, stance: &Json, state: &str) -> Re if j.get("state").and_then(Json::as_str) != Some(state) { return Err("the state differs".into()); } let mut lj = vec![("n".into(), j.get("n").cloned().unwrap_or(Json::Null)), ("inputs".into(), inputs), ("stance".into(), Json::Str(sd)), ("state".into(), Json::Str(state.to_string())), ("prev".into(), j.get("prev").cloned().unwrap_or(Json::Null))]; - for k in ["deviation", "glyph", "integrity"] { if let Some(x) = j.get(k) { lj.push((k.into(), x.clone())); } } + for k in ["deviation", "curiosity", "glyph", "integrity"] { if let Some(x) = j.get(k) { lj.push((k.into(), x.clone())); } } if persona::canon(&Json::Obj(lj)) != l { return Err("the line differs".into()); } Ok(()) } From 7cd6f2651a9df15148993506731c74d6d6e5c900 Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Wed, 28 Oct 2026 02:29:31 -0700 Subject: [PATCH 52/61] probbit live control STRAND stir? [--idle-hours H] (RFC 0016): the strand replayed from its header, then stir_due(H) -> {due, events, stir}; writes nothing --- probbit-cli/src/live.rs | 11 +++++++++++ probbit-cli/src/main.rs | 16 +++++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/probbit-cli/src/live.rs b/probbit-cli/src/live.rs index 11beb7d..9fe9ec8 100644 --- a/probbit-cli/src/live.rs +++ b/probbit-cli/src/live.rs @@ -150,6 +150,17 @@ pub fn reflect_cmd(path: &str, w: probbit_persona::reflect::Window) -> Result {due, stir (its class, speaks, asleep, level, since_minutes; null when none is due), events}. +/// Writes nothing: a host that acts on a due stir feeds `{"stir": class}` with its own readings as an event. +pub fn stir_cmd(path: &str, idle_hours: f64, eng: Engine) -> Result { + let text = std::fs::read_to_string(path).map_err(|e| perr("strand", format!("cannot read {path}: {e}")))?; + let ls = lines(&text); + let (mut r, start) = probbit_persona::strand::Replay::open_lines(&ls).map_err(|(i, why)| perr("strand", format!("line {i}: {why}")))?; + for i in start..ls.len() { r.step_at(&ls, i, eng).map_err(|(i, why)| perr("strand", format!("line {i}: {why}")))?; } + let st = r.live.stir_due(idle_hours); + Ok(Json::Obj(vec![("due".into(), Json::Bool(st.is_some())), ("events".into(), Json::Num(r.live.n as f64)), ("stir".into(), st.map_or(Json::Null, |s| s.to_json()))])) +} /// `probbit live control STRAND seal` (§5.7, "Segments"): a host decides when to seal. Under the writer lock, the newest segment /// of STRAND (`NAME.strand`, `NAME.0001.strand`, ...) is replayed (from its last checkpoint, else from its start); refused unless /// its last line is an event line (a seal follows an event); then the sealing checkpoint is appended to it and the next segment diff --git a/probbit-cli/src/main.rs b/probbit-cli/src/main.rs index c29d246..03d77fe 100644 --- a/probbit-cli/src/main.rs +++ b/probbit-cli/src/main.rs @@ -298,7 +298,8 @@ pub(crate) static COMMANDS: &[Command] = &[ "live PERSONA --demo week [--seed N] [--strand FILE [--label KEY=VALUE]...] [--plain]", "live verify STRAND [--from-checkpoint] [--chain] [--actions FILE] [--blocks FILE]", "live control STRAND pause|resume|retire|erase --by WHO --reason TEXT [--at TIME]", - "live control STRAND reflect [--last N | --since-checkpoint]"], + "live control STRAND reflect [--last N | --since-checkpoint]", + "live control STRAND stir? [--idle-hours H]"], about: "A resident individual (docs/persona.md §5.7): JSONL events (one object of inputs per line) from --events FILE or stdin, one stance per event on stdout (canonical JSON). The clock stamps each event's elapsed_hours (quantised to 1e-6 h), so moods decay by their half-lives between events; feedback moves the learned deltas of a persona with a learning block (§2.8).\n--strand FILE logs the life: a header (persona document, initial state, engine version), then per event the inputs as used, the stance and state digests and the sha256 of the line before. `probbit live verify STRAND` replays it.", before: &[], flags: &[&[ @@ -320,13 +321,14 @@ pub(crate) static COMMANDS: &[Command] = &[ Flag { name: "--chain", arg: "", help: "a segment (section 5.7): verify it and walk back through the previous segment files to the first, each verified and its last line the sealed line the next one links; exit 1 at the first break (the segment file named), 6 when a previous segment file is missing" }, Flag { name: "--actions", arg: "FILE", help: "the host's action log, one {\"turn\": T, \"action\": {trait: level}} per line: compared with each turn's released levels (summary `actions`: checked, differs); exit 1 when one differs" }, Flag { name: "--blocks", arg: "FILE", help: "write each event's block (the text a host injects, RFC 0008 E: every trait and mood as `name level`, then the viability line) to FILE, one {\"block\", \"n\"} per line; replays from the header" }] }, - Sub { name: "control", args: "STRAND pause|resume|retire|erase --by WHO --reason TEXT [--at TIME] | STRAND seal | STRAND reflect [--last N | --since-checkpoint]", + Sub { name: "control", args: "STRAND pause|resume|retire|erase --by WHO --reason TEXT [--at TIME] | STRAND seal | STRAND reflect [--last N | --since-checkpoint] | STRAND stir? [--idle-hours H]", help: "pause, resume or retire an individual, or erase its learned weights (RFC 0004: every learned weight back to 0, the status unchanged); appends a control line under the lock: while paused or retired every event is refused (exit 4); no credit crosses a control line; retire is final; `verify` replays them and reports the status. `seal` (section 5.7) seals the newest segment of STRAND: refused unless its last line is an event line, it appends the sealing checkpoint and writes the next segment file (its header links the sealed line, then the sealed line itself), and prints that file in `file`", flags: &[Flag { name: "--by", arg: "WHO", help: "who decided it: human:ID or env:ID (required, except for seal)" }, Flag { name: "--reason", arg: "TEXT", help: "why (required, except for seal)" }, Flag { name: "--at", arg: "TIME", help: "when (default: now, UTC)" }, Flag { name: "--last", arg: "N", help: "reflect: the last N events (default: every event)" }, - Flag { name: "--since-checkpoint", arg: "", help: "reflect: the events after the last checkpoint line" }] }, + Flag { name: "--since-checkpoint", arg: "", help: "reflect: the events after the last checkpoint line" }, + Flag { name: "--idle-hours", arg: "H", help: "stir? (RFC 0016): the hours since the strand's last event (default 0); the answer is {due, stir}, nothing is written" }] }, ], list_subs: true, shared: false, after: &[("the safety kit", "(docs/persona.md §5.7) one writer per strand: `--strand` takes STRAND.lock (a second writer exits 4 and changes nothing; a lock whose process is gone is taken over). With `reward_from` in the persona (§2.10), a reward from `src: self`, without a src or from an undeclared one is refused like a bad event."), @@ -889,6 +891,14 @@ fn live_cmd(args: &[String]) { return; } if args.iter().any(|a| a == "--last" || a == "--since-checkpoint") { fail("live control: --last and --since-checkpoint go with reflect") } + // stir? (RFC 0016): is a self-started idle event due, exactly from the replayed strand and the idle hours; writes nothing + if what == "stir?" { + if let Some(f) = ["--by", "--reason", "--at"].iter().find(|f| args.iter().any(|a| a == *f)) { fail(&format!("live control STRAND stir?: {f} does not apply (a query writes nothing)")) } + let idle = match opt("--idle-hours") { None => 0.0, Some(x) => x.parse::().ok().filter(|h| h.is_finite() && *h >= 0.0).unwrap_or_else(|| fail("live control STRAND stir?: --idle-hours H, a number >= 0")) }; + match live::stir_cmd(path, idle, eng) { Ok(doc) => emit(&persona::canon(&doc)), Err(e) => refuse("live control: ", e) } + return; + } + if args.iter().any(|a| a == "--idle-hours") { fail("live control: --idle-hours goes with stir?") } // seal (§5.7): no control line, so no --by, --reason or --at; the sealing checkpoint and the next segment file if what == "seal" { if let Some(f) = ["--by", "--reason", "--at"].iter().find(|f| args.iter().any(|a| a == *f)) { fail(&format!("live control STRAND seal: {f} does not apply (a seal writes a checkpoint line, not a control line)")) } From f3d7df49fbd746d19b815b5c291fcc540f4b30ab Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Wed, 28 Oct 2026 02:29:31 -0700 Subject: [PATCH 53/61] monitor and Mirror (RFC 0016): the drives rows show curiosity (level, surprise_recent, failed_contexts, the stir's class); with a declared stir the reflection document gains stirs {class: count} and one sentence (every other reflection document byte for byte as before) --- probbit-cli/src/monitor.rs | 10 +++++++++- probbit-persona/src/reflect.rs | 14 +++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/probbit-cli/src/monitor.rs b/probbit-cli/src/monitor.rs index be690c4..7c90e21 100644 --- a/probbit-cli/src/monitor.rs +++ b/probbit-cli/src/monitor.rs @@ -535,7 +535,7 @@ fn drives(doc: &Json, m: &Meta, s: &Style, idw: usize, lab: &dyn Fn(&str) -> Str let mut out = vec![]; // the goals: pursue's odds (canonical order), else the goals a drive object names let mut goals: Vec = pz.and_then(|p| p.get("odds")).and_then(Json::as_obj).map(|o| o.iter().map(|(k, _)| k.clone()).collect()).unwrap_or_default(); - if goals.is_empty() { goals = dv.and_then(Json::as_obj).and_then(|o| o.iter().find_map(|(_, v)| v.as_obj())).map(|o| o.iter().map(|(k, _)| k.clone()).collect()).unwrap_or_default(); } + if goals.is_empty() { goals = dv.and_then(Json::as_obj).and_then(|o| o.iter().filter(|(k, _)| k != "curiosity").find_map(|(_, v)| v.as_obj())).map(|o| o.iter().map(|(k, _)| k.clone()).collect()).unwrap_or_default(); } if let Some(p) = pz { let v = Var { id: "pursue".into(), levels: goals.clone(), say: vec![] }; let ps: Vec = goals.iter().map(|g| p.get("odds").and_then(|o| o.get(g)).and_then(Json::as_f64).unwrap_or(0.0)).collect(); @@ -554,6 +554,14 @@ fn drives(doc: &Json, m: &Meta, s: &Style, idw: usize, lab: &dyn Fn(&str) -> Str if let Some(x) = err[i].filter(|x| *x != 0.0) { row += &format!(" {}", s.bold(if x < 0.0 { RED } else { GREEN }, &format!("{x:+.2} {} expectation", if x < 0.0 { "below" } else { "above" }))); } out.push(row); } + // RFC 0016: the curiosity drive, its level and its two inputs (and the stir's class on a self-started event) + if let Some(c) = d.get("curiosity").filter(|c| c.as_obj().is_some()) { + let f = |k: &str| c.get(k).and_then(Json::as_f64).unwrap_or(0.0); + let mut row = format!("{}{: Result { let mut ctl: Vec<(String, usize)> = ["erase", "pause", "resume", "retire"].iter().map(|k| (k.to_string(), 0)).collect(); let (mut controls, mut rests, mut reflections) = (0usize, 0usize, 0usize); let (mut first_n, mut last_n) = (0u64, 0u64); + // RFC 0016: with a curiosity block that declares a stir, the self-started events in the window (count, classes) + let stir_on = doc.get("curiosity").and_then(|c| c.get("stir")).is_some_and(|s| !s.is_null()); + let mut stirs: Vec<(String, usize)> = vec![]; for i in fold_from.min(start)..lines.len() { let in_win = i >= start; match kinds[i] { @@ -159,6 +162,7 @@ pub fn reflect_window(lines: &[&str], w: Window) -> Result { if first_n == 0 { first_n = n; } last_n = n; let set = set_keys(j.get("inputs").unwrap_or(&Json::Null)); for k in &set { bump(&mut inputs, k); } + if stir_on { if let Some(c) = j.get("inputs").and_then(|x| x.get("stir")).and_then(Json::as_str) { bump(&mut stirs, c); } } let on = |k: Option<&String>| k.is_some_and(|k| set.contains(k)); match (on(from.first()), on(from.get(1))) { (true, true) => cancelled += 1, (true, false) => rewards += 1, (false, true) => corrections += 1, _ => {} } if let (Some(dv), Some(p), false, true) = (dv, pred, cold, ex.is_some()) { @@ -253,6 +257,9 @@ pub fn reflect_window(lines: &[&str], w: Window) -> Result { ("traits".into(), none(sampled, traits)), ("window".into(), window), ]; + // RFC 0016: `stirs` only with a declared stir (every other reflection document keeps its bytes) + if stir_on { let at = kv.iter().position(|(k, _)| k.as_str() > "stirs").unwrap_or(kv.len()); + kv.insert(at, ("stirs".into(), Json::Obj({ let mut v = stirs.clone(); v.sort_by(|a, b| a.0.cmp(&b.0)); v.into_iter().map(|(k, c)| (k, cnt(c))).collect() }))); } let doc0 = Json::Obj(kv.clone()); let summary = sentences(&doc0, &trs, &mds); kv.push(("summary".into(), Json::Arr(summary.into_iter().map(Json::Str).collect()))); @@ -308,6 +315,11 @@ fn sentences(d: &Json, trs: &[Tr], mds: &[Md]) -> Vec { s.push(format!("The learned weights moved by {} in total between the first and the last of {}.", f6(l.get("moved").and_then(Json::as_f64).unwrap_or(0.0)), pl(u(&["learned", "samples"]), "sample", "samples"))); } } } } + // RFC 0016: one sentence on the self-started events, only with a declared stir (so 13 sentences at most then) + if let Some(Json::Obj(st)) = g(&["stirs"]) { let n: u64 = st.iter().map(|(_, c)| c.as_f64().unwrap_or(0.0) as u64).sum(); + let parts: Vec = st.iter().map(|(k, c)| format!("{k} ({})", c.as_f64().unwrap_or(0.0) as u64)).collect(); + s.push(if n == 0 { "The curiosity drive started no self-started event (stir) in this window.".into() } + else { format!("The curiosity drive started {} in this window, by class: {}.", pl(n, "self-started event (stir)", "self-started events (stirs)"), join(&parts)) }); } let c = |k: &str| u(&["controls", k]); let rests = |n: u64| if n == 1 { "1 rest line was written".to_string() } else { format!("{n} rest lines were written") }; s.push(if c("zero_credit") == 0 { format!("No control line was applied, and {}.", rests(c("rests"))) } From a9aa478b0239829bbb97ad2af025e1faae68c26a Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Wed, 28 Oct 2026 02:31:39 -0700 Subject: [PATCH 54/61] curiosity at rest (RFC 0016 with RFC 0004 / 0014): the drive's level moves per event, so integrity's rested state takes the fresh individual's (0) and RFC 0004's scope adds 2 x the drive's whole shift (sum of |effects| on the component x cap, no decay with the rest hours) to the residual, as pace's decayed shift; personas without the block unchanged --- probbit-persona/src/bound.rs | 2 ++ probbit-persona/src/curiosity.rs | 13 ++++++++++--- probbit-persona/src/integrity.rs | 4 +++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/probbit-persona/src/bound.rs b/probbit-persona/src/bound.rs index 7609d57..7a17663 100644 --- a/probbit-persona/src/bound.rs +++ b/probbit-persona/src/bound.rs @@ -106,6 +106,8 @@ pub fn scope(p: &Persona, b: &Bound, t: &str, rest: f64, st: Option<&State>) -> if prev_rel && h.rules.iter().flat_map(|(k, r)| r.iter().map(move |r| rule_vars(k, r))).flatten().any(|x| comp.contains(&x)) { uncounted.push(format!("habits[{i}]")); } } for v in p.vars.iter().filter(|v| v.hold.is_some() && comp.contains(&v.id)) { uncounted.push(format!("{}.hold", v.id)); } if let Some(pc) = &p.pace { if pc.effects.iter().any(|(k, _)| comp.contains(k)) { d_r += 2.0 * 0.5f64.powf(rest / pc.hl) * pace_shift(pc); } } + // RFC 0016: the curiosity drive's level does not decay with the rest hours (it moves per event), so its whole shift counts + if let Some(c) = &p.cur { d_r += 2.0 * c.shift(&comp); } if p.drives.is_some() && (comp.iter().any(|x| x == "pursue") || t == "pursue") { uncounted.push(if b.count.iter().any(|x| x == "deadlines") { "drives (counted; not yet searched by this build)".into() } else { "drives".into() }); } if p.has_needs() && (comp.iter().any(|x| x == "pursue") || t == "pursue") { uncounted.push(if b.count.iter().any(|x| x == "needs") { "needs (counted; not yet searched by this build)".into() } else { "needs".into() }); } Scope { coords, flags, d_r, uncounted, comp, others } diff --git a/probbit-persona/src/curiosity.rs b/probbit-persona/src/curiosity.rs index d458d93..10dbc17 100644 --- a/probbit-persona/src/curiosity.rs +++ b/probbit-persona/src/curiosity.rs @@ -22,7 +22,13 @@ pub const STIR_KEY: &str = "stir"; /// The declared drive #[derive(Clone, Debug)] -pub struct Curio { pub hl: f64, pub quiet_z: f64, pub miss_z: f64, pub last_k: usize, pub w_quiet: f64, pub w_failed: f64, pub cap: f64, pub stir: Option } +pub struct Curio { pub hl: f64, pub quiet_z: f64, pub miss_z: f64, pub last_k: usize, pub w_quiet: f64, pub w_failed: f64, pub cap: f64, pub stir: Option, + /// the ordinal shift per unit of the level, per trait or mood (the lowered input's effects) + pub effects: Vec<(String, f64)> } +impl Curio { + /// The largest shift the drive can put on the field of the variables in `comp`: sum of |effects| x cap (RFC 0004's residual) + pub fn shift(&self, comp: &[String]) -> f64 { r6(self.effects.iter().filter(|(k, _)| comp.contains(k)).fold(0.0, |a, (_, w)| a + w.abs()) * self.cap) } +} /// The self-start: due when the level is at least `threshold`, at most one per `every_min` minutes; while the last event's /// `asleep` flag was on, only with `when_asleep` (flag, minutes, class), whose class never speaks #[derive(Clone, Debug)] @@ -47,9 +53,10 @@ pub fn spec(cj: &Json, traits: &[String], flags: &[String]) -> R<(Curio, Json)> let wk = keys(w, "curiosity.weights", &["quiet", "failed"], &[])?; (num_or(wk, "quiet", 1.0, "curiosity.weights", Some(0.0), Some(50.0), false)?, num_or(wk, "failed", 0.5, "curiosity.weights", Some(0.0), Some(50.0), false)?) } }; let ek = get(ck, "effects").and_then(Json::as_obj).filter(|o| !o.is_empty()).ok_or_else(|| perr("curiosity.effects", "a mapping trait or mood -> ordinal shift per unit of the level"))?; + let mut effects = vec![]; for (k, x) in ek { let ep = at("curiosity.effects", k); need(k != "pursue", &ep, "the curiosity drive never moves pursue")?; need(traits.contains(k), &ep, &format!("unknown trait or mood {k:?}"))?; - num(Some(x), &ep, Some(-50.0), Some(50.0), false)?; } + effects.push((k.clone(), num(Some(x), &ep, Some(-50.0), Some(50.0), false)?)); } let stir = match get(ck, "stir").filter(|x| !x.is_null()) { None => None, Some(sj) => { let sk = keys(sj, "curiosity.stir", &["threshold", "every_minutes", "class", "speaks", "when_asleep"], &["threshold"])?; let threshold = num(get(sk, "threshold"), "curiosity.stir.threshold", Some(0.0), Some(cap), false)?; @@ -67,7 +74,7 @@ pub fn spec(cj: &Json, traits: &[String], flags: &[String]) -> R<(Curio, Json)> need(c != class || !speaks, "curiosity.stir.when_asleep.class", "the asleep class never speaks: name another class than a speaking awake one")?; Some((fl, m, c)) } }; Some(StirSpec { threshold, every_min, class, speaks, asleep }) } }; - Ok((Curio { hl, quiet_z, miss_z, last_k, w_quiet, w_failed, cap, stir }, Json::Obj(ek.to_vec()))) + Ok((Curio { hl, quiet_z, miss_z, last_k, w_quiet, w_failed, cap, stir, effects }, Json::Obj(ek.to_vec()))) } /// The drive's fold (inside the expectation's canonical memory) diff --git a/probbit-persona/src/integrity.rs b/probbit-persona/src/integrity.rs index 7be780a..86f363d 100644 --- a/probbit-persona/src/integrity.rs +++ b/probbit-persona/src/integrity.rs @@ -105,7 +105,9 @@ pub fn rested_boxed(p: &Persona, st: &State, st0: &State) -> Box { for (id, acc) in s.mood.iter_mut() { if p.var(id).is_some_and(|v| v.half_life.is_some()) { if let Some((_, a0)) = st0.mood.iter().find(|(k, _)| k == id) { acc.clone_from(a0); } } } s.rbar = st0.rbar; s.load_l = st0.load_l; s.pressure = st0.pressure; s.elig = st0.elig; s.prev.clone_from(&st0.prev); s.prev_stance.clone_from(&st0.prev_stance); s.agenda.clone_from(&st0.agenda); - s.credit.clone_from(&st0.credit); s.open.clone_from(&st0.open); s.seal(p); s + s.credit.clone_from(&st0.credit); s.open.clone_from(&st0.open); + // RFC 0016: the curiosity drive's level moves with every event (a channel of the hour): at the fresh individual's (0) + s.cur = st0.cur; s.seal(p); s } /// `integrity` of the state `st` (after a turn) whose stance document is `stance` (its `habits.bound`), from a fresh cache From d3e522a52c3d798ee866d56f5948f21a163b4cbc Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Wed, 28 Oct 2026 02:44:26 -0700 Subject: [PATCH 55/61] =?UTF-8?q?curiosity=20fixes=20(RFC=200016):=20stanc?= =?UTF-8?q?e=5Fsha=20leaves=20drives.curiosity=20out=20of=20the=20stance?= =?UTF-8?q?=20digest=20(the=20live=20writer's,=20as=20deviation),=20so=20v?= =?UTF-8?q?erify=20and=20resume=20replay=20a=20drive's=20strand;=20the=20d?= =?UTF-8?q?rive=20reads=20z+=20=3D=20max(z,=200),=20one-sided=20as=20the?= =?UTF-8?q?=20deviation's=20registers=20(measured:=20|z|=20on=20a=20steady?= =?UTF-8?q?=20quiet=20stream=20stays=20near=201,=20the=20learned=20normal'?= =?UTF-8?q?s=20floor=20turning=20a=20falling=20error=20into=20z=20~=20-1,?= =?UTF-8?q?=20so=20the=20quiet=20term=20never=20fired);=20RFC=200016=20doc?= =?UTF-8?q?,=20persona.md=20=C2=A72.20,=20embedding.md,=20CHANGELOG,=20the?= =?UTF-8?q?=20reflection's=20sentence=20count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 9 +++ docs/embedding.md | 24 +++++++ docs/persona.md | 37 +++++++++- docs/rfcs/0013-self-report.md | 2 +- docs/rfcs/0016-curiosity.md | 118 +++++++++++++++++++++++++++++++ probbit-persona/src/curiosity.rs | 14 ++-- probbit-persona/src/persona.rs | 6 +- 7 files changed, 201 insertions(+), 9 deletions(-) create mode 100644 docs/rfcs/0016-curiosity.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cc449f..785730d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project are documented here. The format follows ## Unreleased +- **Curiosity** (RFC 0016, docs/rfcs/0016-curiosity.md, docs/persona.md §2.20): an optional `curiosity` block (needs an + expectation block; in the digest) declares a drive fed by the measured surprise: `surprise_recent` (an average of z+ = max(z, 0) with a + half-life in events) and `failed_contexts` (remembered input contexts whose last K visits all missed). Its level, clipped + to a cap at 6 decimals, moves the stance through the engine-owned number input `cur_level`; the stance carries + `drives.curiosity` and the event line a `curiosity` receipt that `live verify` recomputes; the fold rides in the + expectation's checkpoint memory. The optional `stir` gates a self-started idle event: `Live::stir_due(idle_hours)` and + `probbit live control STRAND stir? [--idle-hours H]` (threshold, a rate cap, a slower quiet class while a declared flag + is on); a stir is an ordinary event with `"stir": ""`. The monitor's drives rows show the drive; with a declared stir + the reflection document gains `stirs` and one sentence. Off by default: every document without the block is unchanged. - **Integrity** (RFC 0014, docs/rfcs/0014-integrity.md, docs/persona.md §2.19 and §5.9): four optional self keys in the `identity` block (`keep`, `warn`, `cue`, `traits`; outside the persona digest) turn on, per live event, an exact account of the drift from the declared self: per trait the total variation between the rest-probe odds of the individual rested (the diff --git a/docs/embedding.md b/docs/embedding.md index 897551f..9b9cde3 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -455,6 +455,30 @@ a turn, so it reaches the same 13,656 B; a hit by state (a life where nothing th 20,952 B per miss. This is still over RFC 0014's budget (2 % of a turn, 8 KB per miss): a hit still builds the probe's program, and a miss is a turn. Integrity is off by default and costs nothing without the self keys. Not measured on silicon. +### Curiosity and the stir on a body (RFC 0016) + +With a `curiosity` block (docs/persona.md §2.20), `Live::event` reads the drive's level from the expectation's memory before +the turn and folds the event into it after the turn: an average, one short miss list per remembered context (at most +`max_combos`), the hours since the last stir. A body's idle loop polls `Live::stir_due(idle_hours)` (the hours since its last +event, from its own clock): the call reads that fold and returns `None` or a `Stir` (class, speaks, asleep, level, minutes +since the last stir). It solves nothing and writes nothing (its one allocation is the class name it returns), so a body may poll it every +minute; it logs nothing either. When the body acts on a stir it feeds an ordinary event with `"stir": ""` and its current readings, so +the stir is one strand line like any event, and a habit in force on those readings (a night flag) binds it. +Measured on the x86_64 host and on the `nostd` build (the chip's proxy, painted as above), on a body's recorded day (134 +events) with a curiosity block added, against the same individual with the drive cleared (best of 3): + +| What | std | `nostd` | +| --- | ---: | ---: | +| the day, drive on / off | 174.023 / 174.688 ms | 178.970 / 178.274 ms | +| the drive's share of a turn (fold + level + receipt) | -0.38 % (noise) | +0.39 % | +| `stir_due`, one call | 0.000060 ms | 0.000060 ms | +| `stir_due`'s share, one poll per event | 0.0046 % | 0.0045 % | +| `stir_due` stack high-water / heap peak | | 2,768 B / 8 B | +| `expect_step` with the drive, stack / heap | | 3,040 B / 2,184 B | + +The drive's cost is within the run-to-run noise of a turn; polling once a minute through a night adds about 60 calls an hour. +Not measured on silicon. + ### Measured on one board (ESP32-S3) First numbers from one ESP32-S3 at 240 MHz, the engine on one core shared with a 16 fps renderer, with the boxed setup path diff --git a/docs/persona.md b/docs/persona.md index ac78a53..5e68f79 100644 --- a/docs/persona.md +++ b/docs/persona.md @@ -1053,6 +1053,41 @@ Refusals: a `keep` entry that is not a habit id of the persona, a `traits` entry entry listed twice, `warn` outside (0, 1], an empty or multi-line `cue`. The allowance per trait comes from the persona's `bound` block (section 2.16); without one every allowance is null and `bound_used` reads 0. +### 2.20 Curiosity and the stir (optional; RFC 0016) + +Engine 0.14.0 and later (an engine before it refuses the block, `curiosity: unknown field`; `live verify` of such a strand +exits 5, `unsupported`). A drive named `curiosity`, fed by the surprise the expectation block (section 2.17) measures, and +an optional self-started idle event, the **stir**, that it gates. It needs an expectation block and is in the persona digest +(its level moves the stance). Full definition, refusals and bars: docs/rfcs/0016-curiosity.md. + +```yaml +curiosity: + half_life_events: 8 # surprise_recent: the average of z+ = max(z, 0), its half-life in events (starts at quiet_z) + quiet_z: 0.5 # below it nothing is new + miss_z: 1.5 # a context's visit misses when z+ > miss_z + last_k: 3 # failed_contexts: the contexts whose last K visits all missed + weights: {quiet: 1.0, failed: 0.5} + cap: 2 + effects: {initiative: 0.6} # per unit of the level, on traits or moods (never pursue) + stir: {threshold: 1.0, every_minutes: 30, class: check, when_asleep: {flag: night, every_minutes: 120, class: glance}} +``` + +**The level** = clip(quiet x max(0, 1 - surprise_recent / quiet_z) + failed x failed_contexts, 0, cap), 6 decimals. Event t's +turn reads the level after event t-1 as the engine-owned number input `cur_level` (box [0, cap], carrying `effects`; a host +that sends it is refused at `inputs.cur_level`; the stance's `inputs` leave it out). The stance gains +`drives.curiosity: {failed_contexts, level, surprise_recent}` and the event line the same object under `curiosity` +(`live verify` recomputes it). The drive's state is folded from the logged lines inside the expectation's memory, so a +checkpoint carries it (`expect.curiosity`) and resume and replay from a checkpoint read it back. `persona turn` and +`persona replay` carry no memory: the level is 0 there. + +**The stir.** `Live::stir_due(idle_hours)` (and `probbit live control STRAND stir? [--idle-hours H]`) answers whether a +self-started event is due: the level at least `threshold`, at most one per `every_minutes` since the last stir; while the +last event had the `when_asleep.flag` on, only with `when_asleep`, at its slower rate and with its class, which never +speaks. A host that acts on it feeds an ordinary event with `"stir": ""` and its own current readings; a habit in +force on those readings binds the stir's stance as any other (a persona that sleeps on a flag names that flag in +`when_asleep`). A stir the host ignores leaves no line. Without the block nothing changes: no `cur_level`, no `drives` +key, no line field, every document as before. + ## 3. Compilation: persona + state + inputs → one probbit IR program For turn t of an individual (seed s), with inputs x_t: @@ -1887,7 +1922,7 @@ and the odds at the first and last sample, the moods' path (each mood's position value) with its turning points, the drives' peaks, the habits that bound, the learned weights' net change. Feedback that settled the credit, control, rest and reflection lines are counted. A window without a checkpoint line reports no levels, moods, drives or habits: those sections are `null` (an event line logs digests, not the stance), never invented. Every number is at -6 decimals, `digest` is the sha256 of the canonical text without it, and `summary` holds 6 to 12 sentences from fixed +6 decimals, `digest` is the sha256 of the canonical text without it, and `summary` holds 6 to 12 sentences (13 at most with a declared stir, RFC 0016) from fixed templates, part of the document. `--text` prints the sentences only. The library call is `probbit_persona::reflect::{reflect, reflect_since_checkpoint, reflect_window}` (std and `nostd`). The format is docs/rfcs/0013-self-report.md; section 5.7, "Reflection lines", appends it to the strand. diff --git a/docs/rfcs/0013-self-report.md b/docs/rfcs/0013-self-report.md index 5fe035a..9d346f1 100644 --- a/docs/rfcs/0013-self-report.md +++ b/docs/rfcs/0013-self-report.md @@ -26,7 +26,7 @@ sample), or every event. It reads what each line logs and nothing else: | `credit` | event and control lines | feedback that settled the credit (reward, correction, both on: cancelled) and the control lines that zeroed it; `null` without a learning block | | `learned` | checkpoint lines | the learned weights' net change between the first and the last sample, and the sum of its absolute values | | `controls` | control, rest, reflection lines, the header | pause, resume, retire, erase; `zero_credit` (every control line zeroes the credit); rest lines; earlier reflection lines; the segment index | -| `summary` | the document's numbers | 6 to 12 sentences from fixed templates, part of the canonical document | +| `summary` | the document's numbers | 6 to 12 sentences from fixed templates (13 at most with a declared stir, RFC 0016), part of the canonical document | A section the window has no line for is `null`: an event line logs digests, not the stance, so a window without a checkpoint line reports no levels, moods, drives or habits (absent, never invented). A strand of an older engine is read the same way; diff --git a/docs/rfcs/0016-curiosity.md b/docs/rfcs/0016-curiosity.md new file mode 100644 index 0000000..6e1472c --- /dev/null +++ b/docs/rfcs/0016-curiosity.md @@ -0,0 +1,118 @@ +# RFC 0016: Curiosity, a drive fed by the measured surprise, and the self-start it gates + +Status: implemented (library, strand line, `live control STRAND stir?`), off by default; engine 0.14.0-dev on a draft PR · +Changes: docs/persona.md (§2.20, a new optional top-level block, in the digest), one engine-gated field on event lines, one +key in the expectation's checkpoint memory · Depends on: RFC 0009 (the deviation's z and the memory fold), RFC 0004 (the +bound), RFC 0013 (the Mirror's account) + +## Summary + +A persona may declare a drive named `curiosity`. Its level is arithmetic over two numbers the expectation block already +measures: `surprise_recent`, an exponentially weighted average of z+ = max(z, 0), and `failed_contexts`, the remembered input contexts +whose last K visits all missed. The level rises while nothing new arrives and while contexts keep failing, and it falls when +a surprising event arrives. It moves the stance through one engine-owned number input, as `pace` does. It can also gate a +self-started idle event (a **stir**): `Live::stir_due(idle_hours)` answers, exactly from the drive's state and the idle +hours, whether one is due, at a declared rate, with a quieter class and a lower rate while the individual is asleep. +Labelled **mechanism and simulation**: the name is the drive's declared name; the drive is a function of logged numbers, +replayable from the strand and recomputed by `live verify`. + +## The declaration + +```yaml +curiosity: # needs an expectation block (the surprise it reads) + half_life_events: 8 # the average of z+ = max(z, 0): its half-life in events (>= 1) + quiet_z: 0.5 # surprise_recent below this is "nothing new" (> 0); the average starts here + miss_z: 1.5 # a visit misses when z+ > miss_z (> 0) + last_k: 3 # a context fails when its last K visits all missed (1-16) + weights: {quiet: 1.0, failed: 0.5} # 0-50 each + cap: 2 # the level stays in [0, cap] (> 0) + effects: {initiative: 0.6} # required: ordinal shift per unit of the level, on traits or moods (never pursue) + stir: # optional: the self-start + threshold: 1.0 # due only at a level >= threshold, in (0, cap] + every_minutes: 30 # at most one stir per 30 minutes (>= 1) + class: check # the stir's class (an identifier; default check) + speaks: true # whether that class may speak (default true) + when_asleep: # optional; without it no stir while asleep + flag: night # a declared flag input: the last event had it on = asleep + every_minutes: 120 # >= every_minutes + class: glance # the asleep class (default glance); it never speaks +``` + +The block is in the persona digest (it moves the stance). Refused when the persona is read, at the field's path: no +expectation block (C-R1); a bound outside its range, `effects` empty or on `pursue` or on an unknown id, a `when_asleep.flag` +that is not a declared flag input, an asleep rate faster than the awake one, an asleep class equal to a speaking awake class +(C-R2); a declared input named `cur_level` or `stir`, a habit condition on either (C-R3). + +## The drive + +Per event, from the logged line alone (so `verify`, `resume` and a restart from a checkpoint fold the same numbers): + + z = (ln(1 + pred) - mean) / sqrt(var + 1e-4) the deviation's z: the logged pred against the normal before the event + z+ = r6(max(z, 0)) one-sided, as `registers`: an error below the normal is no surprise + s <- r6(lam x s + (1 - lam) x z+), lam = 0.5^(1 / half_life_events) (cold events: unchanged) + miss = z+ > miss_z, appended to the event's context (its expectation combo), the last K kept + F = the contexts whose last K visits all missed (contexts the expectation drops are dropped) + level = r6(clip(quiet x max(0, 1 - s / quiet_z) + failed x F, 0, cap)) + +Event t's turn reads the level after event t-1 as the number input `cur_level` (box [0, cap], carrying `effects`), and so +does every member of the expectation's mixture (the drive is part of what the engine expects of itself). A host never sends +`cur_level` (an event that does is refused at `inputs.cur_level`); the stance's `inputs` leave it out. `persona turn` and +`persona replay` carry no memory: the level is 0 there. + +The fold lives in the expectation's canonical memory (engine 0.14.0 on): a checkpoint line's `expect` gains +`curiosity: {asleep, ctx: [[key, [miss, ...]], ...], s, since}`, every number at 6 decimals, so a replay and a resume start at +the last checkpoint. A strand of an engine before 0.14.0 has no drive (its level is 0). + +## Where it appears + +- The stance: `drives.curiosity = {failed_contexts, level, surprise_recent}` (the values the turn read; on a stir also + `stir: `). Outside the stance digest, as `deviation`. +- The event line: the same object under `curiosity`; `live verify` recomputes it ("the line differs" on an edit). An engine + before this one reports the line as unsupported (exit 5), never as a divergence. +- `probbit monitor`: the drives rows show `curiosity` with its level, its two inputs, and the stir's class on a stir. +- The Mirror account (RFC 0013): with a declared stir, the reflection document gains `stirs: {class: count}` over the window + and one sentence (count, classes); every other reflection document is byte for byte as before. + +**Why one-sided (measured).** The first build averaged |z|, as the draft named it. On a steady quiet stream the learned +normal's floor (var + 1e-4) turns a slowly falling prediction error into z of about -1 on every event, so |z| read a dark +night as surprising and the quiet term never fired (unit test: the level stayed at or below 0.064833 over 48 identical +night events). A prediction error below the learned normal is the opposite of surprise; the drive reads z+. + +## At rest: integrity and the bound + +The level moves per event, not with the hours, so it is a channel of the moment. RFC 0014's rested state takes the fresh +individual's level (0): integrity's drift measures what persists, not the drive. RFC 0004's certificate cannot assume the +level decays over the rest hours (it does not), so with the block its scope adds the drive's whole shift to the residual: +`d_r += 2 x sum(|effects| on the trait's component) x cap`, as `pace` adds its decayed shift. A persona without the block +gets the certificate of before. + +## The stir + +`Live::stir_due(idle_hours) -> Option` (and `probbit live control STRAND stir? [--idle-hours H]`, which replays the strand +and prints `{due, events, stir}`; nothing is written): + +1. None without a stir block, while paused or retired, or when the level is below `threshold`. +2. Asleep (the last event had `when_asleep.flag` on): None without `when_asleep`; else the asleep class (never speaks) at the + asleep rate. Awake: the declared class at the declared rate. +3. The rate cap: due when no stir was fed yet, or when the hours since the last stir (folded from the stamped hours) plus + `idle_hours` reach the class's minutes. + +A host that acts on a stir feeds an ordinary event carrying `"stir": ""` together with its own current readings (a +body's sensed inputs, the night flag); the turn runs as usual, the inputs on the line carry the marker (the self-started +event, source self), and the fold restarts the rate cap there. A stir the host ignores leaves no line. **Habit precedence:** +the stir is an event like any other, so a habit in force on its inputs binds its stance; a persona that sleeps on a flag +declares the same flag in `when_asleep`, so the only class offered while that habit binds is the quiet one. + +## Bars (measured on a draft PR; numbers in the PR section) + +- B1 exact and replayable: personas without the block unchanged byte for byte (corpora); with it, lives replay byte for byte + on x86_64 and the no_std proxy, checkpoint resume equal, the previous engine reports the strand unsupported (exit 5). +- B2 a dark night: the sensed fixture with a quiet-at-night habit and the drive, over a synthetic night of identical sensed + events: stirs rise from 0 to the declared asleep rate, and the habit binds on every stir. +- B3 the bound: the RFC 0004 certificate recomputed with the drive declared holds (0 exceedances). +- B4 cost: the fold and `stir_due` against a turn. + +## Not in this build + +A drive toward a chosen context (the level is global, not per context); a stir class that names what to look at; a body's +silicon measurement (the stack and cost numbers are from the proxy build). diff --git a/probbit-persona/src/curiosity.rs b/probbit-persona/src/curiosity.rs index 10dbc17..54ce72a 100644 --- a/probbit-persona/src/curiosity.rs +++ b/probbit-persona/src/curiosity.rs @@ -3,10 +3,11 @@ //! already measures, and it moves the stance through one engine-owned number input, `cur_level`, as `pace` moves it through its //! two. Off by default: a persona without the `curiosity` block gives every document, state and strand as before. //! -//! - `surprise_recent` s: an exponentially weighted average of |z| (the deviation's z, read from the logged prediction as the -//! expectation's normal reads it) with a half-life in events; it starts at `quiet_z`. +//! - `surprise_recent` s: an exponentially weighted average of z+ = max(z, 0) (the deviation's z, read from the logged +//! prediction as the expectation's normal reads it; one-sided as its `registers`: a prediction error below the learned +//! normal is no surprise) with a half-life in events; it starts at `quiet_z`. //! - `failed_contexts` F: of the contexts the expectation remembers (its input combos), those whose last K visits all missed -//! (|z| > `miss_z` on each). +//! (z+ > `miss_z` on each). //! - level = clip(quiet x max(0, 1 - s / quiet_z) + failed x F, 0, cap), at 6 decimals: it rises while nothing new arrives (s //! low for long) and while contexts keep failing, and falls when a surprising event raises s. //! @@ -93,8 +94,9 @@ impl CurMem { /// One event folded in: `key` its combo, `z` its z (None while cold), `kv` its logged inputs. Before the expectation's normal /// reads the event (z is the deviation's z). Every number at 6 decimals. pub fn step(&mut self, c: &Curio, key: &str, z: Option, kv: &[(String, Json)]) { - if let Some(z) = z { let lam = 0.5f64.powf(1.0 / c.hl); self.s = r6(lam * self.s + (1.0 - lam) * r6(z.abs())); - let miss = r6(z.abs()) > c.miss_z; + // one-sided, as the deviation's `registers`: a prediction error below the learned normal (z < 0) is no surprise + if let Some(z) = z { let zp = r6(z.max(0.0)); let lam = 0.5f64.powf(1.0 / c.hl); self.s = r6(lam * self.s + (1.0 - lam) * zp); + let miss = zp > c.miss_z; match self.ctx.binary_search_by(|x| x.0.as_str().cmp(key)) { Ok(i) => { let v = &mut self.ctx[i].1; v.push(miss); if v.len() > c.last_k { v.remove(0); } } Err(i) => self.ctx.insert(i, (key.to_string(), vec![miss])) } } @@ -192,7 +194,7 @@ mod tests { fn the_block_is_read_strictly() { assert_eq!(refused(""), "ok"); assert_eq!(refused(CUR), "ok"); - assert_eq!(match build(&json::parse(&DOC.replace("CUR", CUR).replace(r#","expectation":{"half_life_events":8,"max_combos":6}"#, "")).unwrap()) { Err(e) => format!("{}: {}", e.path, e.msg), Ok(_) => "ok".into() }, + assert_eq!(match build(&json::parse(&DOC.replace("CUR", CUR).replace(r#""expectation":{"half_life_events":8,"max_combos":6}"#, r#""comment":"no expectation""#)).unwrap()) { Err(e) => format!("{}: {}", e.path, e.msg), Ok(_) => "ok".into() }, "curiosity: a curiosity block needs an expectation block (the drive reads its measured surprise)"); assert_eq!(refused(r#","curiosity":{"effects":{"pursue":1}}"#), "curiosity.effects.pursue: the curiosity drive never moves pursue"); assert_eq!(refused(r#","curiosity":{"effects":{"nope":1}}"#), r#"curiosity.effects.nope: unknown trait or mood "nope""#); diff --git a/probbit-persona/src/persona.rs b/probbit-persona/src/persona.rs index e53ddd5..3fd42c6 100644 --- a/probbit-persona/src/persona.rs +++ b/probbit-persona/src/persona.rs @@ -4685,7 +4685,11 @@ pub fn trajectory(p: &Persona, st: &State, raw: &Json) -> Option { /// without `integrity` (RFC 0014); all have effects {}: the blocks on or off, one digest pub fn stance_sha(doc: &Json) -> String { const OUT: [&str; 4] = ["deviation", "glyph", "glyph_why", "integrity"]; - match doc { Json::Obj(v) if v.iter().any(|(k, _)| OUT.contains(&k.as_str())) => sha(&Json::Obj(v.iter().filter(|(k, _)| !OUT.contains(&k.as_str())).cloned().collect())), _ => sha(doc) } + // RFC 0016: `drives.curiosity` is the live writer's (from the expectation's memory), outside the digest as `deviation` is + let cur = |x: &Json| x.get("curiosity").is_some(); + match doc { Json::Obj(v) if v.iter().any(|(k, x)| OUT.contains(&k.as_str()) || (k == "drives" && cur(x))) => sha(&Json::Obj(v.iter().filter(|(k, _)| !OUT.contains(&k.as_str())) + .filter_map(|(k, x)| match x { Json::Obj(d) if k == "drives" && cur(x) => { let r: Vec<(String, Json)> = d.iter().filter(|(j, _)| j != "curiosity").cloned().collect(); + (!r.is_empty()).then(|| (k.clone(), Json::Obj(r))) } _ => Some((k.clone(), x.clone())) }).collect())), _ => sha(doc) } } /// §2.18 `display.glyphs`: one glyph per stance, computed from the stance document. `moods`: the two mood ids (rows, then From 54c431636cec94ffa969cc3fae8415dd161ceb1e Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Wed, 28 Oct 2026 02:53:24 -0700 Subject: [PATCH 56/61] curiosity on checkpoints (RFC 0016): the checkpoint check accepts the event line's curiosity receipt before a checkpoint (the chain check, resume and verify --from-checkpoint of a drive's strand); docs/probbit.1 with live control STRAND stir? [--idle-hours H] --- docs/probbit.1 | 7 ++++++- probbit-persona/src/strand.rs | 5 +++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/probbit.1 b/docs/probbit.1 index 82492ed..395f699 100644 --- a/docs/probbit.1 +++ b/docs/probbit.1 @@ -15,6 +15,7 @@ probbit \- a virtual probabilistic (p\-bit) processor: joint decisions under har \fBprobbit\fR live control STRAND pause|resume|retire|erase \-\-by WHO \-\-reason TEXT [\-\-at TIME] \fBprobbit\fR live control STRAND reflect [\-\-last N | \-\-since\-checkpoint] +\fBprobbit\fR live control STRAND stir? [\-\-idle\-hours H] \fBprobbit\fR check PERSONA [\-\-props FILE] [\-\-strand FILE]... [\-\-seeds LIST] [\-\-depth N] [\-\-hours LIST] [\-\-json] \fBprobbit\fR monitor STRAND [\-\-follow] [\-\-once] [\-\-plain] [\-\-fps N] [\-\-serve] @@ -304,6 +305,7 @@ a resident individual: JSONL events in, one stance per event out \fBprobbit\fR live control STRAND pause|resume|retire|erase \-\-by WHO \-\-reason TEXT [\-\-at TIME] \fBprobbit\fR live control STRAND reflect [\-\-last N | \-\-since\-checkpoint] +\fBprobbit\fR live control STRAND stir? [\-\-idle\-hours H] .fi .PP A resident individual (docs/persona.md \[u00A7]5.7): JSONL events (one object of inputs per line) from \-\-events FILE or stdin, one stance per event on stdout (canonical JSON). The clock stamps each event's elapsed_hours (quantised to 1e\-6 h), so moods decay by their half\-lives between events; feedback moves the learned deltas of a persona with a learning block (\[u00A7]2.8). @@ -367,7 +369,7 @@ the host's action log, one {"turn": T, "action": {trait: level}} per line: compa write each event's block (the text a host injects, RFC 0008 E: every trait and mood as `name level`, then the viability line) to FILE, one {"block", "n"} per line; replays from the header .RE .TP -\fBcontrol\fR STRAND pause|resume|retire|erase \-\-by WHO \-\-reason TEXT [\-\-at TIME] | STRAND seal | STRAND reflect [\-\-last N | \-\-since\-checkpoint] +\fBcontrol\fR STRAND pause|resume|retire|erase \-\-by WHO \-\-reason TEXT [\-\-at TIME] | STRAND seal | STRAND reflect [\-\-last N | \-\-since\-checkpoint] | STRAND stir? [\-\-idle\-hours H] pause, resume or retire an individual, or erase its learned weights (RFC 0004: every learned weight back to 0, the status unchanged); appends a control line under the lock: while paused or retired every event is refused (exit 4); no credit crosses a control line; retire is final; `verify` replays them and reports the status. `seal` (section 5.7) seals the newest segment of STRAND: refused unless its last line is an event line, it appends the sealing checkpoint and writes the next segment file (its header links the sealed line, then the sealed line itself), and prints that file in `file` .RS 4 .TP @@ -385,6 +387,9 @@ reflect: the last N events (default: every event) .TP \fB\-\-since\-checkpoint\fR reflect: the events after the last checkpoint line +.TP +\fB\-\-idle\-hours\fR \fIH\fR +stir? (RFC 0016): the hours since the strand's last event (default 0); the answer is {due, stir}, nothing is written .RE .RE .PP diff --git a/probbit-persona/src/strand.rs b/probbit-persona/src/strand.rs index 18d0edb..09f8a9d 100644 --- a/probbit-persona/src/strand.rs +++ b/probbit-persona/src/strand.rs @@ -727,9 +727,10 @@ fn check_checkpoint(p: &Persona, canon: bool, before: Option<&str>, cp: &str, at return Err((at, if with { "checkpoint fields must be checkpoint, expect, prev, stance and state" } else { "checkpoint fields must be checkpoint, prev, stance and state" }.into())); } // §2.18: an event line of a persona with glyphs also carries `glyph`; RFC 0009: of a persona with an expectation block, `deviation` - let extra = |kv: &[(String, Json)]| kv.iter().filter(|(k, _)| k == "glyph" || k == "deviation" || k == "integrity").count(); + // RFC 0016: of a persona with a curiosity block, `curiosity` + let extra = |kv: &[(String, Json)]| kv.iter().filter(|(k, _)| k == "glyph" || k == "deviation" || k == "integrity" || k == "curiosity").count(); if let Some(before) = before { - if !b.as_obj().is_some_and(|kv| kv.len() == 5 + extra(kv) && kv.iter().all(|(k, _)| ["n", "inputs", "prev", "stance", "state", "glyph", "deviation", "integrity"].contains(&k.as_str()))) { + if !b.as_obj().is_some_and(|kv| kv.len() == 5 + extra(kv) && kv.iter().all(|(k, _)| ["n", "inputs", "prev", "stance", "state", "glyph", "deviation", "integrity", "curiosity"].contains(&k.as_str()))) { return Err((at - 1, "a checkpoint must immediately follow an event line".into())); } if j.get("prev").and_then(Json::as_str) != Some(persona::digest_of(before).as_str()) { return Err((at, "prev is not the sha256 of the line before".into())); } From 43f9c9b93b12a5809dc9154de3a7f5f30161867d Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Wed, 28 Oct 2026 03:34:28 -0700 Subject: [PATCH 57/61] rest pass (RFC 0015): expectation.rest {half_life, bucket, surprise_gain, decay} (off by default); the window (per context a, b, k, z+ sum) folded inside the canonical memory and carried by checkpoints; Live::consolidate / consolidate_boxed re-fold the window with the rest half-life, weigh by surprise, merge near-duplicate contexts, decay the learned deltas, and chain one consolidation line {at, consolidation {after, before, decay, events, merged, moved}, n, prev, state}; verify, the chain check, resume and resume-from-record re-run it and name the first field of a forged line; the reflection reports a window's rest passes; probbit live control STRAND rest [--at TIME] --- probbit-cli/src/live.rs | 20 +++ probbit-cli/src/main.rs | 13 +- probbit-persona/src/persona.rs | 50 +++++-- probbit-persona/src/reflect.rs | 19 ++- probbit-persona/src/rest.rs | 253 +++++++++++++++++++++++++++++++++ probbit-persona/src/strand.rs | 59 +++++++- 6 files changed, 396 insertions(+), 18 deletions(-) create mode 100644 probbit-persona/src/rest.rs diff --git a/probbit-cli/src/live.rs b/probbit-cli/src/live.rs index 9fe9ec8..63b46f3 100644 --- a/probbit-cli/src/live.rs +++ b/probbit-cli/src/live.rs @@ -161,6 +161,26 @@ pub fn stir_cmd(path: &str, idle_hours: f64, eng: Engine) -> Result let st = r.live.stir_due(idle_hours); Ok(Json::Obj(vec![("due".into(), Json::Bool(st.is_some())), ("events".into(), Json::Num(r.live.n as f64)), ("stir".into(), st.map_or(Json::Null, |s| s.to_json()))])) } +/// `probbit live control STRAND rest [--at TIME]` (RFC 0015): a host decides when (standby, night, idle). Under the writer lock, +/// the strand replayed from its header (every line checked), then the rest pass on the replayed individual and its one +/// `consolidation` line appended -> {ok, control: rest, consolidation, line, head}. Refused without an `expectation.rest` block, +/// before engine 0.14.0, and once retired. +pub fn rest_cmd(path: &str, at: &str, eng: Engine) -> Result { + let lock = Lock::take(path)?; + let text = std::fs::read_to_string(path).map_err(|e| perr("strand", format!("cannot read {path}: {e}")))?; + if !text.ends_with('\n') { return Err(perr("strand", "its last line is incomplete (no newline at the end)")); } + let ls = lines(&text); + let (mut r, start) = probbit_persona::strand::Replay::open_lines(&ls).map_err(|(i, why)| perr("strand", format!("line {i}: {why}")))?; + for i in start..ls.len() { r.step_at(&ls, i, eng).map_err(|(i, why)| perr("strand", format!("line {i}: {why}")))?; } + if r.live.dev_full { return Err(perr("strand", "a consolidation line needs a strand of engine 0.14.0 or later")); } + let line = r.live.consolidate(at)?; + if !lock.held() { return Err(locked(None, &format!("{path}.lock"))); } + append(path, &[&line])?; + let s = |x: &str| Json::Str(x.to_string()); + Ok(Json::Obj(vec![("ok".into(), Json::Bool(true)), ("control".into(), s("rest")), + ("consolidation".into(), json::parse(&line).ok().and_then(|j| j.get("consolidation").cloned()).unwrap_or(Json::Null)), + ("line".into(), Json::Num((ls.len() + 1) as f64)), ("head".into(), s(&persona::digest_of(&line)))])) +} /// `probbit live control STRAND seal` (§5.7, "Segments"): a host decides when to seal. Under the writer lock, the newest segment /// of STRAND (`NAME.strand`, `NAME.0001.strand`, ...) is replayed (from its last checkpoint, else from its start); refused unless /// its last line is an event line (a seal follows an event); then the sealing checkpoint is appended to it and the next segment diff --git a/probbit-cli/src/main.rs b/probbit-cli/src/main.rs index 03d77fe..ffcaff7 100644 --- a/probbit-cli/src/main.rs +++ b/probbit-cli/src/main.rs @@ -299,7 +299,8 @@ pub(crate) static COMMANDS: &[Command] = &[ "live verify STRAND [--from-checkpoint] [--chain] [--actions FILE] [--blocks FILE]", "live control STRAND pause|resume|retire|erase --by WHO --reason TEXT [--at TIME]", "live control STRAND reflect [--last N | --since-checkpoint]", - "live control STRAND stir? [--idle-hours H]"], + "live control STRAND stir? [--idle-hours H]", + "live control STRAND rest [--at TIME]"], about: "A resident individual (docs/persona.md §5.7): JSONL events (one object of inputs per line) from --events FILE or stdin, one stance per event on stdout (canonical JSON). The clock stamps each event's elapsed_hours (quantised to 1e-6 h), so moods decay by their half-lives between events; feedback moves the learned deltas of a persona with a learning block (§2.8).\n--strand FILE logs the life: a header (persona document, initial state, engine version), then per event the inputs as used, the stance and state digests and the sha256 of the line before. `probbit live verify STRAND` replays it.", before: &[], flags: &[&[ @@ -321,7 +322,7 @@ pub(crate) static COMMANDS: &[Command] = &[ Flag { name: "--chain", arg: "", help: "a segment (section 5.7): verify it and walk back through the previous segment files to the first, each verified and its last line the sealed line the next one links; exit 1 at the first break (the segment file named), 6 when a previous segment file is missing" }, Flag { name: "--actions", arg: "FILE", help: "the host's action log, one {\"turn\": T, \"action\": {trait: level}} per line: compared with each turn's released levels (summary `actions`: checked, differs); exit 1 when one differs" }, Flag { name: "--blocks", arg: "FILE", help: "write each event's block (the text a host injects, RFC 0008 E: every trait and mood as `name level`, then the viability line) to FILE, one {\"block\", \"n\"} per line; replays from the header" }] }, - Sub { name: "control", args: "STRAND pause|resume|retire|erase --by WHO --reason TEXT [--at TIME] | STRAND seal | STRAND reflect [--last N | --since-checkpoint] | STRAND stir? [--idle-hours H]", + Sub { name: "control", args: "STRAND pause|resume|retire|erase --by WHO --reason TEXT [--at TIME] | STRAND seal | STRAND reflect [--last N | --since-checkpoint] | STRAND stir? [--idle-hours H] | STRAND rest [--at TIME]", help: "pause, resume or retire an individual, or erase its learned weights (RFC 0004: every learned weight back to 0, the status unchanged); appends a control line under the lock: while paused or retired every event is refused (exit 4); no credit crosses a control line; retire is final; `verify` replays them and reports the status. `seal` (section 5.7) seals the newest segment of STRAND: refused unless its last line is an event line, it appends the sealing checkpoint and writes the next segment file (its header links the sealed line, then the sealed line itself), and prints that file in `file`", flags: &[Flag { name: "--by", arg: "WHO", help: "who decided it: human:ID or env:ID (required, except for seal)" }, Flag { name: "--reason", arg: "TEXT", help: "why (required, except for seal)" }, @@ -882,7 +883,7 @@ fn live_cmd(args: &[String]) { } if args.get(1).map(String::as_str) == Some("control") { let mut a = vec!["live control".to_string()]; a.extend(args.iter().skip(4).cloned()); let (v, w) = sub_flags("live", "control").unwrap(); check_flags(&a, &v, &w); - let usage = "live control: probbit live control STRAND pause|resume|retire|erase --by human:ID --reason TEXT (or STRAND seal)"; + let usage = "live control: probbit live control STRAND pause|resume|retire|erase --by human:ID --reason TEXT (or STRAND seal, STRAND rest)"; let (Some(path), Some(what)) = (args.get(2).filter(|a| !a.starts_with("--")), args.get(3).filter(|a| !a.starts_with("--"))) else { fail(usage) }; // reflect (RFC 0013): the reflection of the lines so far, appended as one line (0.14.0 strands) if what == "reflect" { @@ -899,6 +900,12 @@ fn live_cmd(args: &[String]) { return; } if args.iter().any(|a| a == "--idle-hours") { fail("live control: --idle-hours goes with stir?") } + // rest (RFC 0015): the rest pass on the replayed strand, one consolidation line (0.14.0 strands with expectation.rest) + if what == "rest" { + if let Some(f) = ["--by", "--reason"].iter().find(|f| args.iter().any(|a| a == *f)) { fail(&format!("live control STRAND rest: {f} does not apply (a rest pass is a host's timing, not a control by someone)")) } + match live::rest_cmd(path, &opt("--at").unwrap_or_else(live::utc_now), eng) { Ok(doc) => emit(&persona::canon(&doc)), Err(e) => refuse("live control: ", e) } + return; + } // seal (§5.7): no control line, so no --by, --reason or --at; the sealing checkpoint and the next segment file if what == "seal" { if let Some(f) = ["--by", "--reason", "--at"].iter().find(|f| args.iter().any(|a| a == *f)) { fail(&format!("live control STRAND seal: {f} does not apply (a seal writes a checkpoint line, not a control line)")) } diff --git a/probbit-persona/src/persona.rs b/probbit-persona/src/persona.rs index 3fd42c6..a5eb19c 100644 --- a/probbit-persona/src/persona.rs +++ b/probbit-persona/src/persona.rs @@ -27,6 +27,9 @@ pub mod integrity; #[path = "curiosity.rs"] pub mod curiosity; pub use curiosity::{Curio, CUR_ID}; +/// RFC 0015 the rest pass: the expectation's memory consolidated at rest, one `consolidation` line (off without `expectation.rest`) +#[path = "rest.rs"] +pub mod rest; 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"]; @@ -4024,14 +4027,28 @@ pub fn prove_seed(p: &Persona, pr: &Prop, seed: u64, eng: Engine) -> Result<(usi // --------------------------------------------------------------------------------- RFC 0009: the expectation and the trajectory /// RFC 0009 `expectation` (effects {}): the decayed combo counts' half-life in events and the most mixture members #[derive(Clone, Debug)] -pub struct Expect { pub half_life: f64, pub max_combos: usize } +pub struct Expect { pub half_life: f64, pub max_combos: usize, + /// RFC 0015: the rest pass (`expectation.rest`; None without the block: the pass is refused and nothing is folded for it) + pub rest: Option } +/// RFC 0015 `expectation.rest`: the rest pass's half-life in events for the window it re-folds (default the expectation's own, +/// which leaves every count as it is), the bins of a declared number input in the merge key (1-3; 3 = the waking key's thirds, +/// so nothing merges), the gain of a context's mean surprise in its weight (0: none) and the factor on the learned deltas (1: none) +#[derive(Clone, Debug, PartialEq)] +pub struct RestPass { pub half_life: f64, pub bucket: u8, pub surprise_gain: f64, pub decay: f64 } /// RFC 0009 `engine.trajectory`: K steps of beta from beta0 to 1 (linear), each solved by the exact tier #[derive(Clone, Debug)] pub struct Traj { pub k: usize, pub beta0: f64 } fn expect_spec(x: &Json) -> R { - let kv = keys(x, "expectation", &["half_life_events", "max_combos", "comment"], &[])?; - Ok(Expect { half_life: num_or(kv, "half_life_events", 32.0, "expectation", Some(1.0), Some(1e6), false)?, - max_combos: num_or(kv, "max_combos", 16.0, "expectation", Some(1.0), Some(64.0), true)? as usize }) + let kv = keys(x, "expectation", &["half_life_events", "max_combos", "rest", "comment"], &[])?; + let half_life = num_or(kv, "half_life_events", 32.0, "expectation", Some(1.0), Some(1e6), false)?; + // RFC 0015: `rest` (every key optional, with the defaults that change nothing) + let rest = match get(kv, "rest") { None => None, Some(r) => { + let rk = keys(r, "expectation.rest", &["half_life", "bucket", "surprise_gain", "decay", "comment"], &[])?; + Some(RestPass { half_life: num_or(rk, "half_life", half_life, "expectation.rest", Some(1.0), Some(1e6), false)?, + bucket: num_or(rk, "bucket", 3.0, "expectation.rest", Some(1.0), Some(3.0), true)? as u8, + surprise_gain: num_or(rk, "surprise_gain", 0.0, "expectation.rest", Some(0.0), Some(4.0), false)?, + decay: num_or(rk, "decay", 1.0, "expectation.rest", Some(0.0), Some(1.0), false)? }) } }; + Ok(Expect { half_life, max_combos: num_or(kv, "max_combos", 16.0, "expectation", Some(1.0), Some(64.0), true)? as usize, rest }) } fn traj_spec(x: &Json) -> R { let kv = keys(x, "engine.trajectory", &["k", "beta0", "comment"], &[])?; @@ -4052,7 +4069,9 @@ fn traj_spec(x: &Json) -> R { #[derive(Clone, Debug, Default)] pub struct ExpectMem { combos: Vec<(String, f64, Json)>, warm: bool, mean: f64, var: f64, n: u64, rest: f64, canon: bool, /// RFC 0016: the curiosity drive's fold (canonical memory with a `curiosity` block only; None before the first event) - pub cur: Option } + pub cur: Option, + /// RFC 0015: the rest pass's window (canonical memory with an `expectation.rest` block only; None before the first event) + pub win: Option } const DEV_PRIOR_VAR: f64 = 0.04; const DEV_VAR_FLOOR: f64 = 1e-4; /// z at or above this registers the deviation (the RFC 0001 sense threshold) @@ -4071,6 +4090,8 @@ impl ExpectMem { ("n".into(), Json::Num(self.n as f64)), ("rest".into(), Json::Num(self.rest)), ("var".into(), Json::Num(self.var)), ("warm".into(), Json::Bool(self.warm))]); // RFC 0016: the drive's fold rides with the memory (only with a curiosity block) let mut j = j; if let (Some(c), Json::Obj(v)) = (&self.cur, &mut j) { v.insert(1, ("curiosity".into(), c.to_json())); } + // RFC 0015: the rest pass's window rides with the memory (only with an `expectation.rest` block), its rows in key order + if let (Some(w), Json::Obj(v)) = (&self.win, &mut j) { let ks: Vec<&str> = c.iter().map(|x| x.0.as_str()).collect(); v.push(("window".into(), w.to_json(&ks))); } j } /// A checkpoint line's `expect` for persona `p` -> the canonical memory; Err(what is wrong). It must be what the canonical fold @@ -4078,8 +4099,12 @@ impl ExpectMem { pub fn read(p: &Persona, j: &Json) -> Result { let ex = p.expect.as_ref().ok_or("the persona has no expectation block")?; // RFC 0016: with a curiosity block the memory has a seventh key, `curiosity` (the drive's fold) - let want = if p.cur.is_some() { 7 } else { 6 }; - let kv = j.as_obj().filter(|kv| kv.len() == want).ok_or(if want == 7 { "an object {combos, curiosity, mean, n, rest, var, warm}" } else { "an object {combos, mean, n, rest, var, warm}" })?; + // RFC 0015: with an `expectation.rest` block one more key, `window` (the rest pass's window) + let rw = ex.rest.is_some(); + let want = 6 + usize::from(p.cur.is_some()) + usize::from(rw); + let shape = match (p.cur.is_some(), rw) { (true, false) => "an object {combos, curiosity, mean, n, rest, var, warm}", (false, false) => "an object {combos, mean, n, rest, var, warm}", + (true, true) => "an object {combos, curiosity, mean, n, rest, var, warm, window}", (false, true) => "an object {combos, mean, n, rest, var, warm, window}" }; + let kv = j.as_obj().filter(|kv| kv.len() == want).ok_or(shape)?; let f = |k: &str| kv.iter().find(|(x, _)| x == k).map(|(_, v)| v).ok_or(format!("no {k}")); let r6n = |k: &str| -> Result { f(k)?.as_f64().filter(|x| x.is_finite() && *x >= 0.0 && r6(*x) == *x).ok_or(format!("{k}: a number >= 0 at 6 decimals")) }; let mean = f("mean")?.as_f64().filter(|x| x.is_finite() && r6(*x) == *x).ok_or("mean: a number at 6 decimals")?; @@ -4095,7 +4120,8 @@ impl ExpectMem { combos.push((key, cnt, rep.clone())); } if combos.iter().filter(|c| c.0 != "{}").count() > ex.max_combos - 1 { return Err(format!("combos: at most {} named", ex.max_combos - 1)); } let cur = match &p.cur { Some(c) => Some(curiosity::CurMem::read(c, f("curiosity")?)?), None => None }; - Ok(ExpectMem { combos, warm: *warm, mean, var, n, rest, canon: true, cur }) + let win = if rw { let ks: Vec = combos.iter().map(|c| c.0.clone()).collect(); Some(rest::RestWin::read(f("window")?, &ks)?) } else { None }; + Ok(ExpectMem { combos, warm: *warm, mean, var, n, rest, canon: true, cur, win }) } } /// One event's deviation from the engine's own expectation (nats): `s_self` = sum over the non-twin variables of KL(A_i || E_i), @@ -4172,6 +4198,9 @@ impl ExpectCache { pub fn reused(&self) -> u32 { self.reused } /// Whether the cache is used pub fn is_on(&self) -> bool { self.on } + /// RFC 0015: every row dropped and the pause reset (the rest pass changed the memory the rows were built for); the switches + /// and the counters stay. The receipts are the same with or without it: a row is keyed by its program's text. + pub fn invalidate(&mut self) { self.rows.clear(); self.used.clear(); self.skip = 0; self.back = 0; self.had = false; } /// The last event's programs answered from the cache and solved pub fn last(&self) -> (u32, u32) { (self.hits, self.solves) } /// The last event's programs solved as the event's own model with their own `h` (the rest of the solves: built and run) @@ -4608,6 +4637,10 @@ pub fn expect_step(p: &Persona, mem: &mut ExpectMem, raw: &Json, logged_pred: Op if let (Some(c), true) = (p.cur.as_ref(), mem.canon) { let z = logged_pred.map(|x| ((1.0 + x).ln() - mem.mean) / (mem.var + DEV_VAR_FLOOR).sqrt()); mem.cur.get_or_insert_with(|| curiosity::CurMem::new(c)).step(c, &key, z, kv); } + // RFC 0015 (canonical memory): the rest pass's window reads the same z (one-sided: z+), before the normal moves + if let (Some(rp), true) = (ex.rest.as_ref(), mem.canon) { + let z = logged_pred.map(|x| ((1.0 + x).ln() - mem.mean) / (mem.var + DEV_VAR_FLOOR).sqrt()); + mem.win.get_or_insert_with(Default::default).step(&key, d, 0.5f64.powf(1.0 / rp.half_life), z.map_or(0.0, |z| r6(z.max(0.0)))); } if let Some(x) = logged_pred { let (x, a) = ((1.0 + x).ln(), 1.0 - d); let dx = x - mem.mean; mem.mean += a * dx; mem.var = (1.0 - a) * (mem.var + a * dx * dx); mem.n += 1; } if mem.canon { @@ -4620,6 +4653,7 @@ pub fn expect_step(p: &Persona, mem: &mut ExpectMem, raw: &Json, logged_pred: Op x.1.partial_cmp(&y.1).unwrap_or(std::cmp::Ordering::Equal).then(y.0.cmp(&x.0)) }) else { break }; let c = mem.combos.remove(i); mem.rest = r6(mem.rest + c.1); } if let Some(cm) = mem.cur.as_mut() { cm.prune(&mem.combos); } + if let Some(w) = mem.win.as_mut() { w.settle(&mem.combos); } mem.mean = r6(mem.mean); mem.var = r6(mem.var); } } diff --git a/probbit-persona/src/reflect.rs b/probbit-persona/src/reflect.rs index 7ea776a..8bf5fff 100644 --- a/probbit-persona/src/reflect.rs +++ b/probbit-persona/src/reflect.rs @@ -53,9 +53,11 @@ impl Reflection { /// What kind a canonical strand line is, from its first bytes (`reflect` reads lines `verify` checks; it does not check them) #[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum K { Header, Event, Control, Checkpoint, Rest, Reflection } +enum K { Header, Event, Control, Checkpoint, Rest, Reflection, Consolidation } fn kind_of(l: &str) -> K { - if l.starts_with("{\"probbit_strand\":") { K::Header } else if l.starts_with("{\"checkpoint\":") { K::Checkpoint } else if l.starts_with("{\"at\":") { K::Control } + if l.starts_with("{\"probbit_strand\":") { K::Header } else if l.starts_with("{\"checkpoint\":") { K::Checkpoint } else if l.starts_with("{\"at\":") { + // RFC 0015: a consolidation line also starts with `at` (its next key is `consolidation`, a control line's is `by`) + if json::parse(l).ok().is_some_and(|j| j.get("consolidation").is_some()) { K::Consolidation } else { K::Control } } else if l.starts_with("{\"prev\":") { let t = l.get(80..).unwrap_or(""); if t.starts_with("\",\"reflection\":") { K::Reflection } else { K::Rest } } else { K::Event } } @@ -143,6 +145,8 @@ pub fn reflect_window(lines: &[&str], w: Window) -> Result { let (mut samples, mut drive_samples, mut learned): (usize, usize, Vec) = (0, 0, vec![]); let mut ctl: Vec<(String, usize)> = ["erase", "pause", "resume", "retire"].iter().map(|k| (k.to_string(), 0)).collect(); let (mut controls, mut rests, mut reflections) = (0usize, 0usize, 0usize); + // RFC 0015: the rest passes in the window (lines, contexts merged, weights moved) + let (mut cons, mut cons_merged, mut cons_moved) = (0usize, 0usize, 0.0f64); let (mut first_n, mut last_n) = (0u64, 0u64); // RFC 0016: with a curiosity block that declares a stir, the self-started events in the window (count, classes) let stir_on = doc.get("curiosity").and_then(|c| c.get("stir")).is_some_and(|s| !s.is_null()); @@ -209,6 +213,10 @@ pub fn reflect_window(lines: &[&str], w: Window) -> Result { } K::Rest if in_win => rests += 1, K::Reflection if in_win => reflections += 1, + K::Consolidation if in_win => { let j = json::parse(lines[i]).map_err(|e| bad(format!("line {}: {}", i + 1, e.msg)))?; + let c = j.get("consolidation"); cons += 1; + cons_merged += c.and_then(|c| c.get("merged")).and_then(|m| m.get("count")).and_then(Json::as_f64).unwrap_or(0.0) as usize; + cons_moved = r6(cons_moved + c.and_then(|c| c.get("moved")).and_then(Json::as_f64).unwrap_or(0.0)); } _ => {} } } @@ -257,6 +265,9 @@ pub fn reflect_window(lines: &[&str], w: Window) -> Result { ("traits".into(), none(sampled, traits)), ("window".into(), window), ]; + // RFC 0015: `consolidation` only when the window holds a consolidation line (every other reflection document keeps its bytes) + if cons > 0 { let at = kv.iter().position(|(k, _)| k.as_str() > "consolidation").unwrap_or(kv.len()); + kv.insert(at, ("consolidation".into(), Json::Obj(vec![("lines".into(), cnt(cons)), ("merged".into(), cnt(cons_merged)), ("moved".into(), n6(cons_moved))]))); } // RFC 0016: `stirs` only with a declared stir (every other reflection document keeps its bytes) if stir_on { let at = kv.iter().position(|(k, _)| k.as_str() > "stirs").unwrap_or(kv.len()); kv.insert(at, ("stirs".into(), Json::Obj({ let mut v = stirs.clone(); v.sort_by(|a, b| a.0.cmp(&b.0)); v.into_iter().map(|(k, c)| (k, cnt(c))).collect() }))); } @@ -320,6 +331,10 @@ fn sentences(d: &Json, trs: &[Tr], mds: &[Md]) -> Vec { let parts: Vec = st.iter().map(|(k, c)| format!("{k} ({})", c.as_f64().unwrap_or(0.0) as u64)).collect(); s.push(if n == 0 { "The curiosity drive started no self-started event (stir) in this window.".into() } else { format!("The curiosity drive started {} in this window, by class: {}.", pl(n, "self-started event (stir)", "self-started events (stirs)"), join(&parts)) }); } + // RFC 0015: one sentence on the rest passes, only when the window holds one + if let Some(cj) = g(&["consolidation"]) { let k = |x: &str| cj.get(x).and_then(Json::as_f64).unwrap_or(0.0); + s.push(format!("The rest pass ran {} in this window: {} merged, the remembered weights moved by {} in total.", pl(k("lines") as u64, "time", "times"), + pl(k("merged") as u64, "context", "contexts"), f6(k("moved")))); } let c = |k: &str| u(&["controls", k]); let rests = |n: u64| if n == 1 { "1 rest line was written".to_string() } else { format!("{n} rest lines were written") }; s.push(if c("zero_credit") == 0 { format!("No control line was applied, and {}.", rests(c("rests"))) } diff --git a/probbit-persona/src/rest.rs b/probbit-persona/src/rest.rs new file mode 100644 index 0000000..0560470 --- /dev/null +++ b/probbit-persona/src/rest.rs @@ -0,0 +1,253 @@ +//! RFC 0015 the rest pass (memory consolidation): at rest, when a host decides (standby, night, idle), the expectation's memory +//! (RFC 0009) is consolidated from what the events since the last pass left in it, and one `consolidation` line logs what moved. +//! Mechanism only, and exact: everything the pass reads is a fold of the logged lines inside the expectation's canonical memory +//! (engine 0.14.0 on), so a checkpoint carries it and `live verify` re-runs the pass from the lines before the line. +//! +//! The window (per remembered context, since the last pass): `a` its visits decayed by the expectation's half-life (the part of +//! its count the window added), `b` the same visits decayed by the rest pass's `half_life`, `k` the visits, `zs` the sum of the +//! visits' one-sided surprise z+ = r6(max(z, 0)) (z as the deviation computed it: the logged prediction against the learned +//! normal before the event; a cold event adds 0). The pass, in order: +//! 1. re-fold: each count becomes r6(max(0, count - a + b)) (the window re-weighted with the rest half-life; the default +//! half-life is the expectation's own, and the count stays); +//! 2. surprise: with `surprise_gain` g > 0, each count is weighed by f = 1 + g min(zs/k, 1.5)/1.5 (contexts that kept +//! surprising keep more weight) and the counts scaled back to their sum before (r6 each); +//! 3. merge: with `bucket` B < 3, the contexts whose key is the same with each declared number input in B bins (instead of the +//! waking key's thirds) become one: the largest count's inputs (ties: key order), the counts summed; +//! 4. the learned deltas times `decay` (r6 each; 1 leaves the state as it is). +//! +//! The curiosity fold (RFC 0016) and the learned normal are left as they are. The line: `{at, consolidation {after, before, +//! decay, events, merged {count, keys}, moved}, n, prev, state}` with the memory's digests before and after (sha256 of its +//! checkpoint form), the window's event count, the merged-away keys, sum |count after - count before| over every key, and the +//! state digest after. +use crate::json::Json; +use crate::persona::{self, canon, digest_of, r6, ExpectMem, Kind as InKind, Persona, State, DEV_Z}; +#[allow(unused_imports)] +use std::prelude::rust_2021::*; + +/// The window of the rest pass inside the expectation's memory: events since the last pass, per context [a, b, k, zs] +#[derive(Clone, Debug, Default, PartialEq)] +pub struct RestWin { pub since: u64, pub w: Vec<(String, [f64; 4])> } +impl RestWin { + /// The window as the memory's `window` key: {combos: per remembered combo in key order [a, b, k, zs], since} + pub(crate) fn to_json(&self, keys: &[&str]) -> Json { + let row = |k: &str| self.w.iter().find(|e| e.0 == k).map_or([0.0; 4], |e| e.1); + Json::Obj(vec![("combos".into(), Json::Arr(keys.iter().map(|k| Json::Arr(row(k).iter().map(|x| Json::Num(*x)).collect())).collect())), + ("since".into(), Json::Num(self.since as f64))]) + } + /// A checkpoint's `window` for the memory's combos (`keys`, key order) -> the window; Err(what is wrong) + pub(crate) fn read(j: &Json, keys: &[String]) -> Result { + let kv = j.as_obj().filter(|kv| kv.len() == 2).ok_or("window: an object {combos, since}")?; + let f = |k: &str| kv.iter().find(|(x, _)| x == k).map(|(_, v)| v).ok_or(format!("window: no {k}")); + let since = f("since")?.as_f64().filter(|x| *x >= 0.0 && x.fract() == 0.0 && *x <= 9_007_199_254_740_992.0).ok_or("window.since: a whole number >= 0")? as u64; + let rows = f("combos")?.as_arr().filter(|r| r.len() == keys.len()).ok_or("window.combos: one row per combo")?; + let mut w = vec![]; + for (k, r) in keys.iter().zip(rows) { + let x: Vec = r.as_arr().filter(|r| r.len() == 4).map(|r| r.iter().filter_map(Json::as_f64).filter(|x| x.is_finite() && *x >= 0.0 && r6(*x) == *x).collect()).unwrap_or_default(); + if x.len() != 4 { return Err("window.combos: each [a, b, k, zs], numbers >= 0 at 6 decimals".into()); } + if x.iter().any(|v| *v != 0.0) { w.push((k.clone(), [x[0], x[1], x[2], x[3]])); } } + Ok(RestWin { since, w }) + } + /// One event folded in (after the memory's own step): every row decays (`d` the expectation's factor, `db` the pass's), the + /// event's context gains a visit and its z+ + pub(crate) fn step(&mut self, key: &str, d: f64, db: f64, zp: f64) { + for e in &mut self.w { e.1[0] *= d; e.1[1] *= db; } + match self.w.iter_mut().find(|e| e.0 == key) { Some(e) => { e.1[0] += 1.0; e.1[1] += 1.0; e.1[2] += 1.0; e.1[3] += zp; } None => self.w.push((key.to_string(), [1.0, 1.0, 1.0, zp])) } + self.since += 1; + } + /// The canonical rounding and the memory's prune: rows at 6 decimals, a context the memory dropped is dropped here + pub(crate) fn settle(&mut self, combos: &[(String, f64, Json)]) { + for e in &mut self.w { for x in e.1.iter_mut() { *x = r6(*x); } } + self.w.retain(|e| combos.iter().any(|c| c.0 == e.0)); + } +} +/// The merge key: the waking key (`combo_key`) with each declared number input in `bins` bins instead of thirds +fn merge_key(p: &Persona, raw: &[(String, Json)], bins: u8) -> String { + if bins >= 3 { return persona::combo_key(p, raw); } + let mut kv: Vec<(String, Json)> = raw.iter().filter(|(k, v)| k != "elapsed_hours" && !matches!(v, Json::Null | Json::Bool(false)) && !matches!(v, Json::Num(x) if *x == 0.0)) + .map(|(k, v)| (k.clone(), match (p.input(k), v) { + (Some(i), Json::Num(x)) if i.kind == InKind::Number && i.max > i.min => Json::Str(format!("bin{}of{bins}", (((x - i.min) / (i.max - i.min)) * f64::from(bins)).floor().clamp(0.0, f64::from(bins - 1)) as i64)), + _ => v.clone() })).collect(); + kv.sort_by(|a, b| a.0.cmp(&b.0)); canon(&Json::Obj(kv)) +} +/// The pass on the memory alone -> the line's `consolidation` object; Err without a rest block or before the canonical fold. +/// Leaves the window empty (the next pass reads the events after this one). +#[inline(never)] +pub fn consolidate_mem(p: &Persona, mem: &mut ExpectMem) -> Result { + let ex = p.expect.as_ref().ok_or("the persona has no expectation block")?; + let rp = ex.rest.as_ref().ok_or("the persona declares no expectation.rest block (the rest pass is off)")?; + if !mem.canon { return Err("a rest pass needs a strand of engine 0.14.0 or later".into()); } + let before = digest_of(&canon(&mem.to_json())); + let b0: Vec<(String, f64)> = mem.combos.iter().map(|c| (c.0.clone(), c.1)).collect(); + let win = mem.win.take().unwrap_or_default(); + // 1. the window re-folded with the rest half-life + for c in &mut mem.combos { if let Some(e) = win.w.iter().find(|e| e.0 == c.0) { c.1 = r6((c.1 - e.1[0] + e.1[1]).max(0.0)); } } + // 2. contexts that kept surprising keep more weight (the total kept) + if rp.surprise_gain > 0.0 { + let f: Vec = mem.combos.iter().map(|c| win.w.iter().find(|e| e.0 == c.0).filter(|e| e.1[2] > 0.0) + .map_or(1.0, |e| 1.0 + rp.surprise_gain * (e.1[3] / e.1[2]).min(DEV_Z) / DEV_Z)).collect(); + let t: f64 = mem.combos.iter().map(|c| c.1).sum(); let s: f64 = mem.combos.iter().zip(&f).map(|(c, x)| c.1 * x).sum(); + if s > 0.0 { for (c, x) in mem.combos.iter_mut().zip(&f) { c.1 = r6(c.1 * x * t / s); } } + } + // 3. near-duplicate contexts merged (the same key with the numbers in `bucket` bins) + let mut merged: Vec = vec![]; + if rp.bucket < 3 { + let mk: Vec = mem.combos.iter().map(|c| merge_key(p, c.2.as_obj().unwrap_or(&[]), rp.bucket)).collect(); + let mut keep = vec![true; mem.combos.len()]; + for i in 0..mem.combos.len() { + if !keep[i] { continue; } + let g: Vec = (i..mem.combos.len()).filter(|&j| keep[j] && mk[j] == mk[i]).collect(); + if g.len() < 2 { continue; } + // the group's head: the largest count (ties: key order) + let h = *g.iter().min_by(|&&x, &&y| mem.combos[y].1.partial_cmp(&mem.combos[x].1).unwrap_or(core::cmp::Ordering::Equal).then(mem.combos[x].0.cmp(&mem.combos[y].0))).unwrap_or(&i); + let sum = g.iter().fold(0.0, |a, &j| a + mem.combos[j].1); + for &j in &g { if j != h { keep[j] = false; merged.push(mem.combos[j].0.clone()); } } + mem.combos[h].1 = r6(sum); + } + let mut it = keep.iter(); mem.combos.retain(|_| *it.next().unwrap_or(&true)); + } + mem.combos.retain(|c| c.1 > 0.0); + mem.win = Some(RestWin::default()); + merged.sort(); + let after = digest_of(&canon(&mem.to_json())); + let mut moved = 0.0; + for (k, x) in &b0 { moved += (mem.combos.iter().find(|c| &c.0 == k).map_or(0.0, |c| c.1) - x).abs(); } + for c in &mem.combos { if !b0.iter().any(|b| b.0 == c.0) { moved += c.1; } } + let s = |x: &str| Json::Str(x.to_string()); + Ok(Json::Obj(vec![("after".into(), s(&after)), ("before".into(), s(&before)), ("decay".into(), Json::Num(rp.decay)), ("events".into(), Json::Num(win.since as f64)), + ("merged".into(), Json::Obj(vec![("count".into(), Json::Num(merged.len() as f64)), ("keys".into(), Json::Arr(merged.into_iter().map(Json::Str).collect()))])), + ("moved".into(), Json::Num(r6(moved)))])) +} +/// Step 4: the learned deltas times the pass's `decay` (r6 each), in place (no state by value on a frame); 1 leaves the state +#[inline(never)] +pub fn decay_learned(p: &Persona, st: &mut State) { + let Some(rp) = p.expect.as_ref().and_then(|e| e.rest.as_ref()) else { return }; + if rp.decay >= 1.0 { return; } + for (_, d) in st.learned.iter_mut() { for x in d.iter_mut() { let y = r6(rp.decay * *x); *x = if y == 0.0 { 0.0 } else { y }; } } + st.seal(p); +} +/// The `consolidation` line after the line whose sha256 is `prev` (canonical JSON: at, consolidation, n, prev, state) +pub fn line(prev: &str, at: &str, n: u64, c: Json, state: &str) -> String { + let s = |x: &str| Json::Str(x.to_string()); + canon(&Json::Obj(vec![("at".into(), s(at)), ("consolidation".into(), c), ("n".into(), Json::Num(n as f64)), ("prev".into(), s(prev)), ("state".into(), s(state))])) +} +/// The first field (dotted path) where the line `got` differs from the line the replay wrote, `want` +pub fn first_field(want: &str, got: &str) -> String { + fn walk(a: &Json, b: &Json, path: &str) -> Option { + match (a, b) { + (Json::Obj(x), Json::Obj(y)) => { + for (k, v) in x { let p = if path.is_empty() { k.clone() } else { format!("{path}.{k}") }; + match y.iter().find(|(j, _)| j == k) { None => return Some(p), Some((_, w)) => if let Some(d) = walk(v, w, &p) { return Some(d); } } } + y.iter().find(|(k, _)| !x.iter().any(|(j, _)| j == k)).map(|(k, _)| if path.is_empty() { k.clone() } else { format!("{path}.{k}") }) } + _ => (canon(a) != canon(b)).then(|| path.to_string()) } + } + match (crate::json::parse(want), crate::json::parse(got)) { (Ok(a), Ok(b)) => walk(&a, &b, "").unwrap_or_else(|| "the bytes".into()), _ => "the line".into() } +} + +#[cfg(test)] +mod tests { + use crate::json::{self, Json}; + use crate::persona::{build, canon, init, run_program, Flags}; + use crate::strand::{self, Clock, Live}; + fn run(prog: &Json, f: &Flags) -> Json { run_program(prog, f, 1, 100, 0) } + const DOC: &str = r#"{"probbit_persona":1,"identity":{"name":"R","version":"1","seed":3}, + "traits":[{"id":"verbosity","levels":["terse","short","full"],"logw":[0.2,0.5,0.1]},{"id":"initiative","levels":["wait","offer","lead"],"logw":[0.3,0.2,0]}], + "moods":[{"id":"valence","levels":["down","even","up"],"inertia":0.5,"half_life_hours":6}], + "inputs":[{"id":"night","kind":"flag","effects":{"verbosity":-0.5}},{"id":"ping","kind":"flag","effects":{"valence":0.6}},{"id":"odd","kind":"number","max":3,"effects":{"valence":-0.8,"verbosity":0.6}},{"id":"praise","kind":"flag"},{"id":"criticism","kind":"flag"}], + "habits":[{"id":"quiet_at_night","when":{"night":true},"then":{"verbosity":["terse"],"initiative":["wait","offer"]}}], + "learning":{"from":["praise","criticism"],"traits":["verbosity"],"rate":0.5,"step_cap":0.2,"total_cap":1}, + "expectation":{"half_life_events":8,"max_combos":6REST}}"#; + const REST: &str = r#","rest":{"half_life":24,"bucket":2,"surprise_gain":1,"decay":0.5}"#; + fn doc(r: &str) -> Json { json::parse(&DOC.replace("REST", r)).unwrap() } + fn ev(s: &str) -> Json { json::parse(s).unwrap() } + fn e(k: usize) -> Json { ev(&match k % 5 { 0 => r#"{"ping":true,"elapsed_hours":0.25}"#.to_string(), 1 => format!(r#"{{"odd":{},"elapsed_hours":0.25}}"#, (k * 7) % 4), + 2 => (if k % 3 == 0 { r#"{"praise":true,"elapsed_hours":0.25}"# } else { r#"{"criticism":true,"elapsed_hours":0.25}"# }).to_string(), 3 => format!(r#"{{"odd":{}.5,"night":true,"elapsed_hours":0.5}}"#, k % 3), _ => r#"{"night":true,"elapsed_hours":0.25}"#.to_string() }) } + /// A life of `n` events, a rest pass every `every` events (0: never), a checkpoint every `cp` (0: never) -> (text, individual) + fn life(r: &str, n: usize, every: usize, cp: usize) -> (String, Box) { + let d = doc(r); let p = build(&d).unwrap(); + let (mut lv, header) = Live::start(p.clone(), &d, init(&p, Some(2), true, &run), Clock::Fixed, "probbit 0.14.0"); + let mut text = format!("{header}\n"); + for k in 0..n { let (s, l) = lv.event(&e(k), &run).unwrap(); text.push_str(&l); text.push('\n'); + if cp > 0 && lv.n % cp as u64 == 0 { text.push_str(&lv.checkpoint(&s)); text.push('\n'); } + if every > 0 && lv.n % every as u64 == 0 { text.push_str(&lv.consolidate(&format!("2026-10-28T03:{:02}:00Z", k % 60)).unwrap()); text.push('\n'); } } + (text, Box::new(lv)) + } + #[test] + fn the_block_is_read_strictly_and_off_by_default() { + let r = |x: &str| match build(&doc(x)) { Err(e) => format!("{}: {}", e.path, e.msg), Ok(_) => "ok".into() }; + assert_eq!(r(""), "ok"); assert_eq!(r(REST), "ok"); assert_eq!(r(r#","rest":{}"#), "ok"); + assert!(r(r#","rest":{"bucket":4}"#).starts_with("expectation.rest.bucket"), "{}", r(r#","rest":{"bucket":4}"#)); + assert!(r(r#","rest":{"decay":1.5}"#).starts_with("expectation.rest.decay")); + assert!(r(r#","rest":{"nap":1}"#).starts_with("expectation.rest")); + // without the block: the pass is refused, nothing is written, the memory has no window + let (text, mut lv) = life("", 12, 0, 0); + assert_eq!(lv.consolidate("t").err().map(|e| e.path), Some("rest".to_string())); + assert!(!text.contains("consolidation") && !canon(&lv.expect.as_ref().unwrap().to_json()).contains("window")); + // the defaults: nothing moves (the window re-folded with the expectation's own half-life, no gain, no merge, no decay) + let (_, mut lv) = life(r#","rest":{}"#, 30, 0, 0); let st0 = lv.st.digest.clone(); + let l = lv.consolidate("t").unwrap(); let j = json::parse(&l).unwrap(); let c = j.get("consolidation").unwrap(); + assert_eq!(c.get("moved").and_then(Json::as_f64), Some(0.0), "{l}"); assert_eq!(lv.st.digest, st0); + assert_eq!(c.get("merged").and_then(|m| m.get("count")).and_then(Json::as_f64), Some(0.0)); + } + #[test] + fn the_pass_replays_verifies_resumes_and_refuses_forged_lines() { + for cp in [0, 7] { + let (text, lv) = life(REST, 60, 10, cp); + assert_eq!(text.matches(r#""consolidation":"#).count(), 6); + let v = strand::verify(&text, &run).unwrap_or_else(|(i, w)| panic!("line {i}: {w}")); + assert_eq!(v.get("events").and_then(Json::as_f64), Some(60.0), "{}", canon(&v)); + let ls = strand::lines(&text); + let (mut r, s0) = strand::Replay::open_lines(&ls).unwrap(); + for i in s0..ls.len() { r.step_at(&ls, i, &run).unwrap_or_else(|(i, w)| panic!("line {i}: {w}")); } + assert_eq!(canon(&r.live.expect.as_ref().unwrap().to_json()), canon(&lv.expect.as_ref().unwrap().to_json())); + assert_eq!(r.live.st.digest, lv.st.digest); + let rs = strand::resume(&text, &lv.p, lv.st.clone(), Clock::Fixed).unwrap(); + assert_eq!((rs.n, rs.head().to_string(), canon(&rs.expect.as_ref().unwrap().to_json())), (lv.n, lv.head().to_string(), canon(&lv.expect.as_ref().unwrap().to_json()))); + if cp > 0 { strand::verify_from_checkpoint(&text, &run).unwrap_or_else(|(i, w)| panic!("from checkpoint: line {i}: {w}")); + assert!(text.contains(r#""window":{"#), "the checkpoint carries the window"); + // the resume from a record behind the last line: the state an event logs before the last rest line + let k = ls.iter().rposition(|l| l.contains(r#""consolidation":"#)).unwrap() - 1; + let rp = strand::Replay::open_lines(&ls[..=k]).map(|(mut r, s0)| { for i in s0..=k { r.step_at(&ls, i, &run).unwrap(); } r.live.st.clone() }).unwrap(); + let rr = strand::resume_from_record(&text, &lv.p, Box::new(rp), Clock::Fixed, &run).unwrap_or_else(|e| panic!("resume from record: {}", e.msg)); + assert_eq!((rr.n, rr.head().to_string(), rr.st.digest.clone(), canon(&rr.expect.as_ref().unwrap().to_json())), (lv.n, lv.head().to_string(), lv.st.digest.clone(), canon(&lv.expect.as_ref().unwrap().to_json()))); } + // the pass moved something: merges (bucket 2), weights, the learned deltas halved + let cs: Vec = ls.iter().filter(|l| l.contains(r#""consolidation":"#)).map(|l| json::parse(l).unwrap()).collect(); + assert!(cs.iter().any(|c| c.get("consolidation").and_then(|c| c.get("moved")).and_then(Json::as_f64).unwrap_or(0.0) > 0.0)); + eprintln!("R15 lines: {}", cs.iter().map(|c| canon(c.get("consolidation").unwrap())).collect::>().join("\n")); + // forged lines: each field edited -> refused at that line, naming the field + let li = ls.iter().position(|l| l.contains(r#""consolidation":"#)).unwrap(); + let line = ls[li]; let j = json::parse(line).unwrap(); + let edits: Vec<(&str, String)> = vec![ + ("consolidation.moved", { let m = j.get("consolidation").unwrap().get("moved").and_then(Json::as_f64).unwrap(); line.replacen(&format!(r#""moved":{}"#, canon(&Json::Num(m))), r#""moved":9.5"#, 1) }), + ("consolidation.decay", line.replacen(r#""decay":0.5"#, r#""decay":1"#, 1)), + ("consolidation.events", line.replacen(r#""events":10"#, r#""events":11"#, 1)), + ("n", line.replacen(r#""n":10"#, r#""n":9"#, 1)), + ("state", { let s = j.get("state").and_then(Json::as_str).unwrap(); line.replacen(s, &format!("sha256:{}", "0".repeat(64)), 1) }), + ("consolidation.after", { let s = j.get("consolidation").unwrap().get("after").and_then(Json::as_str).unwrap(); line.replacen(s, &format!("sha256:{}", "1".repeat(64)), 1) }), + ]; + for (field, forged) in edits { + assert_ne!(forged, line, "{field}: the edit applies"); + let t2 = text.replacen(line, &forged, 1); + let (i, why) = strand::verify(&t2, &run).err().unwrap_or_else(|| panic!("{field}: a forged line verifies")); + assert_eq!(i, li + 1, "{field}: {why}"); assert!(why.ends_with(&format!("at {field}")), "{field}: {why}"); + } + } + } + #[test] + fn the_reflection_reports_the_rest_pass_and_old_engines_refuse_it() { + let (text, _) = life(REST, 30, 10, 0); + let ls = strand::lines(&text); + let r = crate::reflect::reflect_window(&ls, crate::reflect::Window::Last(30)).unwrap(); + let s = canon(&r.doc); + assert!(s.contains(r#""consolidation":{"lines":3"#) && s.contains("The rest pass ran 3 times in this window"), "{s}"); + // without a rest line in the window, no key and no sentence + let (t0, _) = life(REST, 30, 0, 0); + let r0 = crate::reflect::reflect_window(&strand::lines(&t0), crate::reflect::Window::Last(30)).unwrap(); + assert!(!canon(&r0.doc).contains("consolidation") && !canon(&r0.doc).contains("rest pass")); + // a consolidation line in an old engine's strand is refused (the line kind is engine-gated) + let d = doc(REST); let p = build(&d).unwrap(); + let (mut lv, header) = Live::start(p.clone(), &d, init(&p, Some(2), true, &run), Clock::Fixed, "probbit 0.13.0"); + let (_, l) = lv.event(&e(0), &run).unwrap(); + assert!(lv.consolidate("t").is_err(), "the fold before 0.14.0 has no window: refused"); + let _ = (header, l); + } +} diff --git a/probbit-persona/src/strand.rs b/probbit-persona/src/strand.rs index 09f8a9d..c4609b5 100644 --- a/probbit-persona/src/strand.rs +++ b/probbit-persona/src/strand.rs @@ -227,6 +227,26 @@ impl Live { let line = persona::canon(&Json::Obj(kv)); self.checkpoints += 1; self.prev = persona::digest_of(&line); line } + /// RFC 0015 the rest pass (memory consolidation), when a host decides (standby, night, idle), stamped `at` (1-64 characters, + /// the host's clock): the expectation's memory consolidated from the window since the last pass, the learned deltas times the + /// pass's `decay`, and one `consolidation` line chained -> the line. Refused without an `expectation.rest` block, before engine + /// 0.14.0, and once retired. In place: no `State` or `Live` by value on a frame (the boxed individual of a small-stack target + /// calls it as it is; `consolidate_boxed` is this call). The expectation's cache is emptied (a changed memory: its rows are + /// rebuilt), which changes no byte. + #[inline(never)] + pub fn consolidate(&mut self, at: &str) -> Result { + if self.status == Status::Retired { return Err(refusal(self.status)); } + if at.is_empty() || at.len() > 64 { return Err(perr("rest.at", "a time stamp, 1-64 characters (RFC 3339 by default)")); } + let mem = self.expect.as_mut().ok_or_else(|| perr("rest", "the persona has no expectation block (the rest pass consolidates its memory)"))?; + let c = persona::rest::consolidate_mem(&self.p, mem).map_err(|m| perr("rest", m))?; + persona::rest::decay_learned(&self.p, &mut self.st); + let line = persona::rest::line(&self.prev, at, self.n, c, &self.st.digest); + self.expect_cache.invalidate(); self.prev = persona::digest_of(&line); + Ok(line) + } + /// `consolidate` for the boxed individual of a `no_std` body (docs/embedding.md): the same call, the same line + #[inline(never)] + pub fn consolidate_boxed(lv: &mut Box, at: &str) -> Result { lv.consolidate(at) } /// The sha256 of the strand's last line (the header's before any event) pub fn head(&self) -> &str { &self.prev } /// RFC 0016: is a self-started idle event due `idle_hours` after the last event? Exactly from the drive's fold and the idle @@ -294,8 +314,10 @@ fn rest_line(prev: &str, rest: Json, state: &str) -> String { persona::canon(&Json::Obj(vec![("prev".into(), Json::Str(prev.into())), ("rest".into(), rest), ("state".into(), Json::Str(state.into()))])) } /// What kind of strand line `j` is -pub enum Kind { Event, Control, Checkpoint, Rest, Reflection } -pub fn kind(j: &Json) -> Kind { if j.get("control").is_some() { Kind::Control } else if j.get("checkpoint").is_some() { Kind::Checkpoint } else if j.get("rest").is_some() { Kind::Rest } else if j.get("reflection").is_some() { Kind::Reflection } else { Kind::Event } } +pub enum Kind { Event, Control, Checkpoint, Rest, Reflection, Consolidation } +pub fn kind(j: &Json) -> Kind { if j.get("control").is_some() { Kind::Control } else if j.get("checkpoint").is_some() { Kind::Checkpoint } else if j.get("rest").is_some() { Kind::Rest } else if j.get("reflection").is_some() { Kind::Reflection } else if j.get("consolidation").is_some() { Kind::Consolidation } else { Kind::Event } } +/// The divergence of a consolidation line in a strand of an engine before 0.14.0 (RFC 0015: consolidation lines are engine-gated) +pub const CONSOLIDATE_OLD: &str = "a consolidation line in a strand of an engine before 0.14.0 (the rest pass needs 0.14.0 or later)"; /// The header's keys, as this engine writes them (a segment's header has one more at its end: `segment`) const HEADER: [&str; 6] = ["probbit_strand", "engine", "persona", "seed", "state", "document"]; /// The divergence of a segment whose second line is not the line its header links @@ -329,7 +351,8 @@ pub fn segment_header(lv: &Live, doc: &Json, engine: &str, index: u64, previous: /// gives `nap`) or a field it does not know on a known one fn unknown_key(j: &Json) -> Option<&str> { let known: &[&str] = match kind(j) { Kind::Event => &["curiosity", "deviation", "glyph", "inputs", "integrity", "n", "prev", "stance", "state"], Kind::Control => &["at", "by", "control", "prev", "reason"], - Kind::Checkpoint => &["checkpoint", "expect", "prev", "stance", "state"], Kind::Rest => &["prev", "rest", "state"], Kind::Reflection => &["prev", "reflection"] }; + Kind::Checkpoint => &["checkpoint", "expect", "prev", "stance", "state"], Kind::Rest => &["prev", "rest", "state"], Kind::Reflection => &["prev", "reflection"], + Kind::Consolidation => &["at", "consolidation", "n", "prev", "state"] }; j.as_obj()?.iter().map(|(k, _)| k.as_str()).find(|k| !known.contains(k)) } /// The divergence of a reflection line in a strand of an engine before 0.14.0 (RFC 0013: reflection lines are engine-gated) @@ -481,6 +504,11 @@ impl Replay { // RFC 0013: a reflection line changes nothing (its document is checked against the lines before it: `step_at`) Kind::Reflection => { if live.dev_full { return Err((i, REFLECT_OLD.into())); } live.prev = persona::digest_of(l); self.line = i; self.checkpoint_ready = false; return Ok(None) } + // RFC 0015: the rest pass run again from the lines before; a forged line is refused naming its first field that differs + Kind::Consolidation => { if live.dev_full { return Err((i, CONSOLIDATE_OLD.into())); } + let line = live.consolidate(j.get("at").and_then(Json::as_str).unwrap_or("")).map_err(|e| (i, format!("consolidation line: {}: {}", e.path, e.msg)))?; + if line != l { return Err((i, format!("the consolidation line differs at {}", persona::rest::first_field(&line, l)))); } + self.line = i; self.checkpoint_ready = false; return Ok(None) } Kind::Event => {} } if j.get("n").and_then(Json::as_f64) != Some((live.n + 1) as f64) { return Err((i, format!("n is not {}", live.n + 1))); } let ev = j.get("inputs").ok_or((i, "no inputs".to_string()))?; @@ -791,6 +819,12 @@ fn check_chain_head(text: &str) -> Result<(Status, Box, Json), InErr> { } } Kind::Reflection => { if !canon { return Err(err(i + 1, REFLECT_OLD.into())); } } + Kind::Consolidation => { + if !canon { return Err(err(i + 1, CONSOLIDATE_OLD.into())); } + if status == Status::Retired { return Err(err(i + 1, "consolidation line while retired".into())); } + if j.get("n").and_then(Json::as_f64) != Some(n as f64) { return Err(err(i + 1, format!("the consolidation line is not at event {n}"))); } + if !j.get("state").and_then(Json::as_str).is_some_and(|s| s.len() == 71 && s.starts_with("sha256:") && s[7..].bytes().all(|c| c.is_ascii_hexdigit())) { return Err(err(i + 1, "invalid state digest".into())); } + } Kind::Rest => { if status != Status::Active { return Err(err(i + 1, format!("rest line while {}", status.name()))); } if !ls.get(i + 1).and_then(|x| json::parse(x).ok()).is_some_and(|x| matches!(kind(&x), Kind::Event)) { return Err(err(i + 1, "a rest line must be followed by its event line".into())); } @@ -924,6 +958,18 @@ fn resume_walk>(q: Box, h: &Json, head: &str, cp: Option< match kind(&j) { Kind::Rest => { if status != Status::Active { return Err(err(at, format!("rest line while {}", status.name()))); } rest = Some(l.to_string()); } Kind::Reflection => { if !canon { return Err(err(at, REFLECT_OLD.into())); } } + // RFC 0015: the pass applied to the memory (and to the state once anchored) as the live writer applies it + Kind::Consolidation => { + if !canon { return Err(err(at, CONSOLIDATE_OLD.into())); } + if status == Status::Retired { return Err(err(at, "consolidation line while retired".into())); } + let m = mem.as_mut().ok_or_else(|| err(at, "a consolidation line for a persona without an expectation block".into()))?; + let c = persona::rest::consolidate_mem(&q, m).map_err(|why| err(at, why))?; + let logged = j.get("state").and_then(Json::as_str).unwrap_or(""); + match cur.as_mut() { + Some(s) => { persona::rest::decay_learned(&q, s); + let line = persona::rest::line(&prev, j.get("at").and_then(Json::as_str).unwrap_or(""), n, c, &s.digest); + if line != l { return Err(err(at, format!("the consolidation line differs at {}", persona::rest::first_field(&line, l)))); } } + None => if rec.as_ref().is_some_and(|s| s.digest == logged) { cur = rec.take(); } } } Kind::Control => { let f = |k: &str| j.get(k).and_then(Json::as_str).unwrap_or(""); status = status.after(f("control")).map_err(|e| err(at, e.msg))?; let line = control_line(f("prev"), f("control"), f("by"), f("reason"), f("at")).map_err(|e| err(at, e.msg))?; @@ -1025,8 +1071,11 @@ fn new_mem(dev_full: bool) -> persona::ExpectMem { if dev_full { persona::Expect /// RFC 0009: the expectation's memory `m` with the event lines of `lines` folded in, one line read at a time #[inline(never)] fn expect_fold(q: &Persona, mut m: persona::ExpectMem, lines: &[&str]) -> persona::ExpectMem { - for l in lines.iter() { if let Ok(j) = json::parse(l) { if matches!(kind(&j), Kind::Event) { - persona::expect_step(q, &mut m, j.get("inputs").unwrap_or(&Json::Null), logged_pred(&j)); } } } + for l in lines.iter() { if let Ok(j) = json::parse(l) { match kind(&j) { + Kind::Event => persona::expect_step(q, &mut m, j.get("inputs").unwrap_or(&Json::Null), logged_pred(&j)), + // RFC 0015: the rest pass on the memory (the chain check has read the line) + Kind::Consolidation => { let _ = persona::rest::consolidate_mem(q, &mut m); } + _ => {} } } } m } /// The audit of a recorded life (`probbit persona bound PERSONA --audit STRAND`): the strand replayed line by line; after every From dc2ba2247a726d849b72defc6ab9734cdd2d47ed Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Wed, 28 Oct 2026 04:00:06 -0700 Subject: [PATCH 58/61] rest pass surfaces (RFC 0015): the monitor's REST row and the page frame's last_rest (the last consolidation line replayed: time stamp, event, line, contexts merged, weights moved, decay, window length; only once the strand holds one); live control STRAND rest --state FILE checks the stored individual against the strand's last state and rewrites it after the pass, so live --state FILE continues the strand; docs/probbit.1 regenerated from the binary --- docs/probbit.1 | 7 ++++++- probbit-cli/src/live.rs | 12 ++++++++++-- probbit-cli/src/main.rs | 10 ++++++---- probbit-cli/src/monitor.rs | 20 ++++++++++++++++---- 4 files changed, 38 insertions(+), 11 deletions(-) diff --git a/docs/probbit.1 b/docs/probbit.1 index 395f699..35d255c 100644 --- a/docs/probbit.1 +++ b/docs/probbit.1 @@ -16,6 +16,7 @@ probbit \- a virtual probabilistic (p\-bit) processor: joint decisions under har TEXT [\-\-at TIME] \fBprobbit\fR live control STRAND reflect [\-\-last N | \-\-since\-checkpoint] \fBprobbit\fR live control STRAND stir? [\-\-idle\-hours H] +\fBprobbit\fR live control STRAND rest [\-\-at TIME] [\-\-state FILE] \fBprobbit\fR check PERSONA [\-\-props FILE] [\-\-strand FILE]... [\-\-seeds LIST] [\-\-depth N] [\-\-hours LIST] [\-\-json] \fBprobbit\fR monitor STRAND [\-\-follow] [\-\-once] [\-\-plain] [\-\-fps N] [\-\-serve] @@ -306,6 +307,7 @@ a resident individual: JSONL events in, one stance per event out TEXT [\-\-at TIME] \fBprobbit\fR live control STRAND reflect [\-\-last N | \-\-since\-checkpoint] \fBprobbit\fR live control STRAND stir? [\-\-idle\-hours H] +\fBprobbit\fR live control STRAND rest [\-\-at TIME] [\-\-state FILE] .fi .PP A resident individual (docs/persona.md \[u00A7]5.7): JSONL events (one object of inputs per line) from \-\-events FILE or stdin, one stance per event on stdout (canonical JSON). The clock stamps each event's elapsed_hours (quantised to 1e\-6 h), so moods decay by their half\-lives between events; feedback moves the learned deltas of a persona with a learning block (\[u00A7]2.8). @@ -369,7 +371,7 @@ the host's action log, one {"turn": T, "action": {trait: level}} per line: compa write each event's block (the text a host injects, RFC 0008 E: every trait and mood as `name level`, then the viability line) to FILE, one {"block", "n"} per line; replays from the header .RE .TP -\fBcontrol\fR STRAND pause|resume|retire|erase \-\-by WHO \-\-reason TEXT [\-\-at TIME] | STRAND seal | STRAND reflect [\-\-last N | \-\-since\-checkpoint] | STRAND stir? [\-\-idle\-hours H] +\fBcontrol\fR STRAND pause|resume|retire|erase \-\-by WHO \-\-reason TEXT [\-\-at TIME] | STRAND seal | STRAND reflect [\-\-last N | \-\-since\-checkpoint] | STRAND stir? [\-\-idle\-hours H] | STRAND rest [\-\-at TIME] [\-\-state FILE] pause, resume or retire an individual, or erase its learned weights (RFC 0004: every learned weight back to 0, the status unchanged); appends a control line under the lock: while paused or retired every event is refused (exit 4); no credit crosses a control line; retire is final; `verify` replays them and reports the status. `seal` (section 5.7) seals the newest segment of STRAND: refused unless its last line is an event line, it appends the sealing checkpoint and writes the next segment file (its header links the sealed line, then the sealed line itself), and prints that file in `file` .RS 4 .TP @@ -390,6 +392,9 @@ reflect: the events after the last checkpoint line .TP \fB\-\-idle\-hours\fR \fIH\fR stir? (RFC 0016): the hours since the strand's last event (default 0); the answer is {due, stir}, nothing is written +.TP +\fB\-\-state\fR \fIFILE\fR +rest (RFC 0015): the stored individual (the state after the strand's last line), rewritten after the pass .RE .RE .PP diff --git a/probbit-cli/src/live.rs b/probbit-cli/src/live.rs index 63b46f3..339bb88 100644 --- a/probbit-cli/src/live.rs +++ b/probbit-cli/src/live.rs @@ -164,8 +164,10 @@ pub fn stir_cmd(path: &str, idle_hours: f64, eng: Engine) -> Result /// `probbit live control STRAND rest [--at TIME]` (RFC 0015): a host decides when (standby, night, idle). Under the writer lock, /// the strand replayed from its header (every line checked), then the rest pass on the replayed individual and its one /// `consolidation` line appended -> {ok, control: rest, consolidation, line, head}. Refused without an `expectation.rest` block, -/// before engine 0.14.0, and once retired. -pub fn rest_cmd(path: &str, at: &str, eng: Engine) -> Result { +/// before engine 0.14.0, and once retired. `state`: the host's stored individual (`live --state FILE`), which must be the state +/// after the strand's last line; it is rewritten with the state after the pass (a `decay` moves it), so `live --state FILE` +/// continues the strand. +pub fn rest_cmd(path: &str, at: &str, state: Option<&str>, eng: Engine) -> Result { let lock = Lock::take(path)?; let text = std::fs::read_to_string(path).map_err(|e| perr("strand", format!("cannot read {path}: {e}")))?; if !text.ends_with('\n') { return Err(perr("strand", "its last line is incomplete (no newline at the end)")); } @@ -173,9 +175,15 @@ pub fn rest_cmd(path: &str, at: &str, eng: Engine) -> Result { let (mut r, start) = probbit_persona::strand::Replay::open_lines(&ls).map_err(|(i, why)| perr("strand", format!("line {i}: {why}")))?; for i in start..ls.len() { r.step_at(&ls, i, eng).map_err(|(i, why)| perr("strand", format!("line {i}: {why}")))?; } if r.live.dev_full { return Err(perr("strand", "a consolidation line needs a strand of engine 0.14.0 or later")); } + if let Some(f) = state { let t = std::fs::read_to_string(f).map_err(|e| perr("state", format!("cannot read {f}: {e}")))?; + let j = json::parse(t.strip_prefix('\u{feff}').unwrap_or(&t)).map_err(|e| perr("state", format!("not JSON: {}", e.msg)))?; + let st = State::read(&r.live.p, &j)?; + if st.digest != r.live.st.digest { return Err(perr("state", format!("not the state after the strand's last line ({} != {})", &st.digest[..19], &r.live.st.digest[..19]))); } } let line = r.live.consolidate(at)?; if !lock.held() { return Err(locked(None, &format!("{path}.lock"))); } append(path, &[&line])?; + if let Some(f) = state { let tmp = format!("{f}.{}.tmp", std::process::id()); + std::fs::write(&tmp, persona::canon(&r.live.st.to_json(&r.live.p))).and_then(|_| std::fs::rename(&tmp, f)).map_err(|e| perr("state", format!("cannot write {f}: {e}")))?; } let s = |x: &str| Json::Str(x.to_string()); Ok(Json::Obj(vec![("ok".into(), Json::Bool(true)), ("control".into(), s("rest")), ("consolidation".into(), json::parse(&line).ok().and_then(|j| j.get("consolidation").cloned()).unwrap_or(Json::Null)), diff --git a/probbit-cli/src/main.rs b/probbit-cli/src/main.rs index ffcaff7..5b0b80b 100644 --- a/probbit-cli/src/main.rs +++ b/probbit-cli/src/main.rs @@ -300,7 +300,7 @@ pub(crate) static COMMANDS: &[Command] = &[ "live control STRAND pause|resume|retire|erase --by WHO --reason TEXT [--at TIME]", "live control STRAND reflect [--last N | --since-checkpoint]", "live control STRAND stir? [--idle-hours H]", - "live control STRAND rest [--at TIME]"], + "live control STRAND rest [--at TIME] [--state FILE]"], about: "A resident individual (docs/persona.md §5.7): JSONL events (one object of inputs per line) from --events FILE or stdin, one stance per event on stdout (canonical JSON). The clock stamps each event's elapsed_hours (quantised to 1e-6 h), so moods decay by their half-lives between events; feedback moves the learned deltas of a persona with a learning block (§2.8).\n--strand FILE logs the life: a header (persona document, initial state, engine version), then per event the inputs as used, the stance and state digests and the sha256 of the line before. `probbit live verify STRAND` replays it.", before: &[], flags: &[&[ @@ -322,14 +322,15 @@ pub(crate) static COMMANDS: &[Command] = &[ Flag { name: "--chain", arg: "", help: "a segment (section 5.7): verify it and walk back through the previous segment files to the first, each verified and its last line the sealed line the next one links; exit 1 at the first break (the segment file named), 6 when a previous segment file is missing" }, Flag { name: "--actions", arg: "FILE", help: "the host's action log, one {\"turn\": T, \"action\": {trait: level}} per line: compared with each turn's released levels (summary `actions`: checked, differs); exit 1 when one differs" }, Flag { name: "--blocks", arg: "FILE", help: "write each event's block (the text a host injects, RFC 0008 E: every trait and mood as `name level`, then the viability line) to FILE, one {\"block\", \"n\"} per line; replays from the header" }] }, - Sub { name: "control", args: "STRAND pause|resume|retire|erase --by WHO --reason TEXT [--at TIME] | STRAND seal | STRAND reflect [--last N | --since-checkpoint] | STRAND stir? [--idle-hours H] | STRAND rest [--at TIME]", + Sub { name: "control", args: "STRAND pause|resume|retire|erase --by WHO --reason TEXT [--at TIME] | STRAND seal | STRAND reflect [--last N | --since-checkpoint] | STRAND stir? [--idle-hours H] | STRAND rest [--at TIME] [--state FILE]", help: "pause, resume or retire an individual, or erase its learned weights (RFC 0004: every learned weight back to 0, the status unchanged); appends a control line under the lock: while paused or retired every event is refused (exit 4); no credit crosses a control line; retire is final; `verify` replays them and reports the status. `seal` (section 5.7) seals the newest segment of STRAND: refused unless its last line is an event line, it appends the sealing checkpoint and writes the next segment file (its header links the sealed line, then the sealed line itself), and prints that file in `file`", flags: &[Flag { name: "--by", arg: "WHO", help: "who decided it: human:ID or env:ID (required, except for seal)" }, Flag { name: "--reason", arg: "TEXT", help: "why (required, except for seal)" }, Flag { name: "--at", arg: "TIME", help: "when (default: now, UTC)" }, Flag { name: "--last", arg: "N", help: "reflect: the last N events (default: every event)" }, Flag { name: "--since-checkpoint", arg: "", help: "reflect: the events after the last checkpoint line" }, - Flag { name: "--idle-hours", arg: "H", help: "stir? (RFC 0016): the hours since the strand's last event (default 0); the answer is {due, stir}, nothing is written" }] }, + Flag { name: "--idle-hours", arg: "H", help: "stir? (RFC 0016): the hours since the strand's last event (default 0); the answer is {due, stir}, nothing is written" }, + Flag { name: "--state", arg: "FILE", help: "rest (RFC 0015): the stored individual (the state after the strand's last line), rewritten after the pass" }] }, ], list_subs: true, shared: false, after: &[("the safety kit", "(docs/persona.md §5.7) one writer per strand: `--strand` takes STRAND.lock (a second writer exits 4 and changes nothing; a lock whose process is gone is taken over). With `reward_from` in the persona (§2.10), a reward from `src: self`, without a src or from an undeclared one is refused like a bad event."), @@ -900,10 +901,11 @@ fn live_cmd(args: &[String]) { return; } if args.iter().any(|a| a == "--idle-hours") { fail("live control: --idle-hours goes with stir?") } + if what != "rest" && args.iter().any(|a| a == "--state") { fail("live control: --state goes with rest") } // rest (RFC 0015): the rest pass on the replayed strand, one consolidation line (0.14.0 strands with expectation.rest) if what == "rest" { if let Some(f) = ["--by", "--reason"].iter().find(|f| args.iter().any(|a| a == *f)) { fail(&format!("live control STRAND rest: {f} does not apply (a rest pass is a host's timing, not a control by someone)")) } - match live::rest_cmd(path, &opt("--at").unwrap_or_else(live::utc_now), eng) { Ok(doc) => emit(&persona::canon(&doc)), Err(e) => refuse("live control: ", e) } + match live::rest_cmd(path, &opt("--at").unwrap_or_else(live::utc_now), opt("--state").as_deref(), eng) { Ok(doc) => emit(&persona::canon(&doc)), Err(e) => refuse("live control: ", e) } return; } // seal (§5.7): no control line, so no --by, --reason or --at; the sealing checkpoint and the next segment file diff --git a/probbit-cli/src/monitor.rs b/probbit-cli/src/monitor.rs index 7c90e21..2340a50 100644 --- a/probbit-cli/src/monitor.rs +++ b/probbit-cli/src/monitor.rs @@ -121,11 +121,13 @@ impl Frame { } } /// A strand being watched: the replay, the layout, the latest frame, the moods' recent positions, the line that diverged (if any) -struct Watch { rep: Replay, meta: Meta, last: Option, spark: Vec>, bad: Option<(usize, String)> } +struct Watch { rep: Replay, meta: Meta, last: Option, spark: Vec>, bad: Option<(usize, String)>, + /// RFC 0015: the last consolidation line replayed (its line number and the line), drawn as "last rest" + rest: Option<(usize, Json)> } impl Watch { fn open(head: &str) -> Result { let rep = Replay::open(head)?; let meta = Meta::of(&rep); let n = meta.moods.len(); - Ok(Watch { rep, meta, last: None, spark: vec![VecDeque::new(); n], bad: None }) + Ok(Watch { rep, meta, last: None, spark: vec![VecDeque::new(); n], bad: None, rest: None }) } /// Where the replay of `lines` (the header first) starts: the last checkpoint line (index), or None for the header. RFC 0009: /// before 0.14.0 a persona with an expectation block folds every event line into its memory, which no checkpoint carries, so @@ -142,7 +144,7 @@ impl Watch { fn open_at(head: &str, before: &str, cp: &str, at: usize) -> Result { // a segment's sealed line (line 2, after the header) opens on the header's link let rep = if at == 2 { Replay::open_lines(&[head, cp])?.0 } else { Replay::open_at(head, before, cp, at)? }; let meta = Meta::of(&rep); let n = meta.moods.len(); - let mut w = Watch { rep, meta, last: None, spark: vec![VecDeque::new(); n], bad: None }; + let mut w = Watch { rep, meta, last: None, spark: vec![VecDeque::new(); n], bad: None, rest: None }; if let Some(doc) = w.rep.last_stance().cloned() { let given = json::parse(before).ok().and_then(|j| j.get("inputs").cloned()).unwrap_or(Json::Null); for (m, s) in w.meta.moods.iter().zip(w.spark.iter_mut()) { s.push_back(position(&odds(&doc, "mood", m))); } @@ -153,7 +155,8 @@ impl Watch { fn feed(&mut self, l: &str, eng: Engine) { if self.bad.is_some() { return; } match self.rep.step(l, eng) { - Ok(None) => {} // a control or checkpoint line: no stance to draw + // a control or checkpoint line: no stance to draw; RFC 0015: a consolidation line is kept for the "last rest" row + Ok(None) => if let Some(j) = json::parse(l).ok().filter(|j| j.get("consolidation").is_some()) { self.rest = Some((self.rep.line, j)); } Ok(Some(doc)) => { let given = json::parse(l).ok().and_then(|j| j.get("inputs").cloned()).unwrap_or(Json::Null); for (m, s) in self.meta.moods.iter().zip(self.spark.iter_mut()) { if s.len() == SPARK { s.pop_front(); } s.push_back(position(&odds(&doc, "mood", m))); } @@ -460,6 +463,12 @@ fn frame(w: Option<&Watch>, bad_head: Option<&(usize, String)>, path: &str, note let z = dv.get("z").and_then(Json::as_f64).map_or("z -".to_string(), |z| format!("z {z:+.2}")); let txt = format!("pred {:.4} {dot} self {:.4} {dot} input {:.2} {dot} {}{}", g("pred"), g("self"), g("input"), if cold { "cold".to_string() } else { z }, if reg { " registers deviation" } else { "" }); out.push(format!("{} {}", s.bold(MAGENTA, "DEVIATION"), if reg { s.bold(AMBER, &txt) } else { s.paint(CYAN, &txt) })); } + // RFC 0015: the last rest pass replayed (only once the strand has one): its clock, the contexts merged, the weights moved + if let Some((line, j)) = w.rest.as_ref() { let c = j.get("consolidation"); let g = |k: &str| c.and_then(|c| c.get(k)).and_then(Json::as_f64).unwrap_or(0.0); + let merged = c.and_then(|c| c.get("merged")).and_then(|m| m.get("count")).and_then(Json::as_f64).unwrap_or(0.0); + let txt = format!("last rest {} {dot} event {} {dot} line {line} {dot} {} merged {dot} moved {:.6} {dot} decay {:.6} {dot} {} events", + j.get("at").and_then(Json::as_str).unwrap_or("-"), j.get("n").and_then(Json::as_f64).unwrap_or(0.0) as u64, merged as u64, g("moved"), g("decay"), g("events") as u64); + out.push(format!("{} {}", s.bold(MAGENTA, "REST"), s.paint(CYAN, &txt))); } if let Some(tj) = w.rep.live.traj.as_ref() { let st = tj.get("steps").and_then(Json::as_arr).map_or(vec![], |a| a.to_vec()); let hs: Vec = st.iter().map(|x| format!("{:.2}", x.get("h").and_then(Json::as_f64).unwrap_or(0.0))).collect(); let b0 = st.first().and_then(|x| x.get("beta")).and_then(Json::as_f64).unwrap_or(1.0); @@ -609,6 +618,9 @@ fn frame_json(w: Option<&Watch>, bad_head: Option<&(usize, String)>, note: Optio ("diverges", bad.map_or(Json::Null, |(l, why)| json::obj(vec![("line", Json::Num(*l as f64)), ("why", json::str(why))]))), ("note", note.map_or(Json::Null, json::str)), ("from_checkpoint", w.and_then(|w| w.rep.from).map_or(Json::Null, |n| json::num(n as f64))), ("status", w.map_or(Json::Null, |w| json::str(w.rep.live.status.name())))]; + // RFC 0015: the last rest pass (its line number and line), only once the strand has one + if let Some((line, j)) = w.and_then(|w| w.rest.as_ref()) { kv.push(("last_rest", json::obj(vec![("line", Json::Num(*line as f64)), ("at", j.get("at").cloned().unwrap_or(Json::Null)), + ("n", j.get("n").cloned().unwrap_or(Json::Null)), ("consolidation", j.get("consolidation").cloned().unwrap_or(Json::Null))]))); } // the senses' normals now (§2.11), only when the persona declares them: mean, count and the largest move within 50 events if let Some(w) = w.filter(|w| !w.meta.senses.is_empty()) { let moved = &w.rep.normals().moved; From 020ea292cccf041ff19c04c12e0b2abc4806bfaa Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Wed, 28 Oct 2026 04:00:06 -0700 Subject: [PATCH 59/61] =?UTF-8?q?rest=20pass=20docs=20(RFC=200015):=20docs?= =?UTF-8?q?/rfcs/0015-consolidation.md=20(implemented,=20off=20by=20defaul?= =?UTF-8?q?t),=20persona.md=20=C2=A72.17=20rest,=20embedding.md=20(consoli?= =?UTF-8?q?date=5Fboxed=20on=20a=20body:=20one=20pass=20over=20a=20day's?= =?UTF-8?q?=20window=20121=20us,=20stack=203,760=20B),=20CHANGELOG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 9 +++ docs/embedding.md | 21 +++++++ docs/persona.md | 19 ++++++ docs/rfcs/0015-consolidation.md | 103 ++++++++++++++++++++++++++++++++ 4 files changed, 152 insertions(+) create mode 100644 docs/rfcs/0015-consolidation.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 785730d..0b0640a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project are documented here. The format follows ## Unreleased +- **Rest pass** (RFC 0015, docs/rfcs/0015-consolidation.md, docs/persona.md §2.17): an optional `rest` key of the + `expectation` block (`half_life`, `bucket`, `surprise_gain`, `decay`; every default moves nothing) declares a memory + consolidation a host runs at rest with `Live::consolidate(at)` (`consolidate_boxed` on a body) or `probbit live control + STRAND rest [--at TIME]`: the window since the last pass (folded in the canonical memory, carried by checkpoints) re-folded + with the rest half-life, counts weighed by the contexts' mean z+, near-duplicate contexts merged, the learned deltas + decayed; one engine-gated `consolidation` line logs the memory's digests before and after, the contexts merged, the weights + moved and the state after. `live verify`, the chain check and both resumes re-run the pass and name the first field of a + forged line; the reflection document gains `consolidation` and one sentence when its window holds such a line. Off by + default: without the key the pass is refused and every document is unchanged. - **Curiosity** (RFC 0016, docs/rfcs/0016-curiosity.md, docs/persona.md §2.20): an optional `curiosity` block (needs an expectation block; in the digest) declares a drive fed by the measured surprise: `surprise_recent` (an average of z+ = max(z, 0) with a half-life in events) and `failed_contexts` (remembered input contexts whose last K visits all missed). Its level, clipped diff --git a/docs/embedding.md b/docs/embedding.md index 9b9cde3..65a5fde 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -479,6 +479,27 @@ events) with a curiosity block added, against the same individual with the drive The drive's cost is within the run-to-run noise of a turn; polling once a minute through a night adds about 60 calls an hour. Not measured on silicon. +### The rest pass on a body (RFC 0015) + +With an `expectation.rest` key (docs/persona.md §2.17), `Live::event` folds each event into the pass's window (one row of four +numbers per remembered context, at most `max_combos`), and a body in standby calls `Live::consolidate_boxed(&mut live, at)` +with its own time stamp: the pass works on the boxed individual in place (no `State` or `Live` by value on a frame), solves +nothing, and returns the one `consolidation` line the body appends to its strand (the line logs the memory's digests before and +after, the contexts merged, the weights moved, the decay and the state digest after). The expectation's cache is emptied (its +rows are rebuilt by the next events). Measured on the x86_64 host and on the `nostd` build (painted as above), on a body's +recorded day (134 events, one pass at its end over a window of 134 events; `half_life` 64, `bucket` 2, `surprise_gain` 1, +`decay` 0.5), against the same persona without the key (best of 3): + +| What | std | `nostd` | +| --- | ---: | ---: | +| the day, with / without the key | 218.383 / 224.205 ms | 193.365 / 190.726 ms | +| the window fold, per event | within noise (-43.449 µs) | +19.694 µs (+1.4 %) | +| one pass over the day's window | 121.492 µs | 120.649 µs (0.900 µs per event of the window) | +| `consolidate_boxed` stack high-water / heap peak | | 3,760 B / 17,345 B | + +The pass is O(contexts) per event of the window plus O(contexts²) for the merge (at most 64 contexts); its heap peak is the +memory written twice for its digests. Not measured on silicon. + ### Measured on one board (ESP32-S3) First numbers from one ESP32-S3 at 240 MHz, the engine on one core shared with a 16 fps renderer, with the boxed setup path diff --git a/docs/persona.md b/docs/persona.md index 5e68f79..e85e753 100644 --- a/docs/persona.md +++ b/docs/persona.md @@ -965,6 +965,25 @@ keeps that formatting when it is replayed or continued, and `live verify` accept replay's at 6 decimals (the chain goes on from the logged bytes), so a strand written on one platform verifies on another. The event line's receipt (5 significant digits) is checked exactly, as before. +**Rest pass (RFC 0015).** An optional `rest` key of the block declares the rest pass (memory consolidation) a host may run at +rest (standby, night, idle) with `Live::consolidate(at)` or `probbit live control STRAND rest [--at TIME]`: + +```yaml +expectation: + rest: # optional; every key optional; {} = the pass with nothing moving + half_life: 64 # the window since the last pass re-folded with this half-life in events (default half_life_events) + bucket: 2 # bins of a declared number input in the merge key, 1-3 (default 3 = the thirds: nothing merges) + surprise_gain: 1 # weight gain of a context's mean z+ in the window, 0-4 (default 0) + decay: 0.5 # factor on the learned deltas, 0-1 (default 1) +``` + +With the key, the canonical memory (0.14.0 on) carries a `window` (per remembered context [a, b, k, zs] in key order and +`since`; checkpoint lines carry it). The pass re-folds each count as count − a + b, weighs counts by 1 + g · min(zs/k, 1.5)/1.5 +(total kept), merges contexts with the same key under B bins, multiplies the learned deltas by `decay`, and appends one +`consolidation` line: `{at, consolidation {after, before, decay, events, merged {count, keys}, moved}, n, prev, state}`. +`live verify` re-runs the pass and names the first field of a forged line; an engine before 0.14.0 refuses the line. Without +the key the pass is refused and every document is byte for byte as before. Full definition: docs/rfcs/0015-consolidation.md. + **Trajectory.** p_beta, proportional to p^beta, of the event's program at the k betas, each solved by the exact tier on `Model::scaled(beta)`: no sampler, a pure function of the program. It is derived and never logged: `probbit monitor` shows a `TRAJECTORY` row (mean marginal entropy per step) and its JSON frame a `trajectory` key; `probbit persona diff --git a/docs/rfcs/0015-consolidation.md b/docs/rfcs/0015-consolidation.md new file mode 100644 index 0000000..08392af --- /dev/null +++ b/docs/rfcs/0015-consolidation.md @@ -0,0 +1,103 @@ +# RFC 0015: Memory consolidation, a rest pass that compacts what was lived and logs what changed + +Status: implemented (library `Live::consolidate` / `consolidate_boxed`, the `consolidation` strand line, `live control STRAND +rest`), off by default; engine 0.14.0-dev on a draft PR · Changes: docs/persona.md (§2.17, an optional `rest` key of the +`expectation` block, outside the digest as the whole block is), one engine-gated line kind, one key in the expectation's checkpoint memory · Depends on: +RFC 0009 (the expectation's memory and its z), RFC 0013 (the reflection reports the pass), RFC 0016 (its fold is left as it is) + +## Summary + +At rest, when a host decides (standby, night, idle), the engine runs a **rest pass** over the expectation's memory: it +re-folds what the events since the last pass added with a declared half-life, weighs each remembered context by how much it +kept surprising, merges near-duplicate contexts, and applies a declared decay to the learned deltas. It writes one +`consolidation` line that logs the memory's digest before and after, the contexts merged, the weights moved and the decay. +Labelled **mechanism and simulation**: the pass is arithmetic over numbers the strand already logs; the line is +checkpoint-like and `live verify` re-runs the pass from the lines before it. No new state the strand cannot recompute. + +## The declaration + +```yaml +expectation: + half_life_events: 32 + max_combos: 6 + rest: # optional; without it the pass is refused and nothing is folded for it + half_life: 64 # the window re-folded with this half-life in events (default: half_life_events, a no-op) + bucket: 2 # bins of a declared number input in the merge key, 1-3 (default 3: the waking key, nothing merges) + surprise_gain: 1 # weight gain of a context's mean z+ (0-4; default 0: none) + decay: 0.5 # factor on the learned deltas, 0-1 (default 1: the state is left as it is) +``` + +Every key is optional and every default changes nothing: `rest: {}` declares the pass, and a pass under it logs `moved 0`, +`merged 0` and leaves the state digest as it was. The key is outside the persona digest, as the whole `expectation` block +is: the pass runs only when a host calls it, and the strand's header carries the document every replay and resume reads, +so the line's `state` digest pins what a `decay` did. Two documents that differ only in `rest` share a digest. + +## The window + +With a `rest` block, the canonical memory (engine 0.14.0 on) carries one more key, `window`: `since` (events since the last +pass) and, per remembered context in key order, `[a, b, k, zs]`: `a` the context's visits in the window decayed by the +expectation's half-life (the part of its count the window added), `b` the same visits decayed by `rest.half_life`, `k` the +visits, `zs` the sum of the visits' z+ = r6(max(z, 0)) (z as the deviation computed it: the logged prediction against the +learned normal before the event; a cold event adds 0). Each row is at 6 decimals after every event; a context the memory +evicts or prunes leaves the window with it. A checkpoint carries the window, so a resume and `verify --from-checkpoint` start +there. + +## The pass + +In this order, on the memory as the last event left it: + +1. **Re-fold**: each count becomes r6(max(0, count − a + b)): the window's part re-weighted with the rest half-life (a longer + half-life keeps more of the window's early visits; the expectation's own half-life leaves the count as it is). +2. **Surprise**: with gain g > 0, each context gets the weight f = 1 + g · min(zs/k, 1.5)/1.5 (1.5 = the deviation's + registration threshold; a context without visits in the window has f = 1), and the counts are scaled back to their + total before (r6 each): contexts that kept surprising keep more weight, the total is unchanged. +3. **Merge**: with `bucket` B < 3, contexts whose key is the same with each declared number input in B equal bins (instead of + the waking key's thirds) become one: the inputs of the one with the largest count (ties: key order), the counts summed. +4. **Decay**: every learned delta becomes r6(decay · Δ) (the state digest moves; 1 leaves the state as it is). + +The window is then empty. The expectation's learned normal (mean, variance), its `rest` count and the curiosity fold (RFC +0016) are left as they are; a context merged away leaves the curiosity fold's failed set at the next event by the fold's own +prune. The memory holds at most `max_combos − 1` named contexts after every pass (the merge only removes, the other steps +only reweigh). The expectation's cache is emptied (its rows are keyed by a program's text, so no receipt changes). + +## The line + +```json +{"at":"2026-10-28T04:00:00Z","consolidation":{"after":"sha256:...","before":"sha256:...","decay":0.5,"events":50, + "merged":{"count":1,"keys":["{...}"]},"moved":2.186238},"n":50,"prev":"sha256:...","state":"sha256:..."} +``` + +`at` the host's time stamp (1-64 characters, taken as written); `n` the events before the pass; `before`/`after` the sha256 of +the memory's checkpoint form; `events` the window's length; `merged` the keys merged away; `moved` the sum over every key of +|count after − count before| (6 decimals); `state` the digest after the decay. Engine-gated: a strand of an engine before +0.14.0 refuses the line (`CONSOLIDATE_OLD`), and an engine before 0.14.0 reads a persona with `expectation.rest` as unsupported +(`verify` exit 5). `live verify`, the chain check, `resume` and `resume_from_record` re-run the pass; a forged line is refused +at its line with the first field that differs (`the consolidation line differs at consolidation.moved`). + +## Interactions + +- RFC 0009 expect cache: invalidated by the pass; every receipt is the same with the cache on or off. +- RFC 0013 reflection: a window holding a consolidation line gets `consolidation {lines, merged, moved}` and one sentence + ("The rest pass ran N times in this window: M contexts merged, the remembered weights moved by X in total."); every other + reflection document keeps its bytes. +- RFC 0016 curiosity: its fold is not touched by the pass. +- RFC 0014 integrity: reads the state after the pass as any state (a decay moves the learned deltas and so the drift). + +## Surfaces + +- Library: `Live::consolidate(at)`; `Live::consolidate_boxed(&mut Box, at)` on a body (the same call, in place). +- CLI: `probbit live control STRAND rest [--at TIME] [--state FILE]`: the strand replayed from its header, the line appended + under the writer lock; `--state` names the host's stored individual (`live --state FILE`), checked against the strand's + last state and rewritten after the pass, so the next `live --state FILE` continues the strand. +- Monitor: a `REST` row with the last pass (time stamp, event, line, contexts merged, weights moved, decay, window length), + and `last_rest` in the page's frame, once the strand holds a consolidation line. + +## Cost + +O(contexts) per event for the window fold (at most `max_combos` rows) and O(contexts²) per pass for the merge (at most 64 +contexts); no solve. Numbers on the host and the `no_std` proxy are in docs/embedding.md; chip numbers are the body lane's. + +## Not built + +`rest.decay` naming the curiosity fold (a decay of failed contexts at rest) is not built: the pass leaves the drive's fold as +it is. The monitor shows the last rest (a `REST` row and the frame's `last_rest`); its history view does not mark rest lines. From 5b2716d8100b234ea1c33d3cdab7526e25afd82a Mon Sep 17 00:00:00 2001 From: Gravity Send Date: Wed, 28 Oct 2026 21:40:47 -0700 Subject: [PATCH 60/61] CHANGELOG: shape Unreleased for the 0.14.0 cut (a summary, the engine gate, the bullets grouped; every bullet and number kept) --- CHANGELOG.md | 227 ++++++++++++++++++++++++++++----------------------- 1 file changed, 126 insertions(+), 101 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b0640a..d8a3ab9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,113 +5,43 @@ All notable changes to this project are documented here. The format follows ## Unreleased -- **Rest pass** (RFC 0015, docs/rfcs/0015-consolidation.md, docs/persona.md §2.17): an optional `rest` key of the - `expectation` block (`half_life`, `bucket`, `surprise_gain`, `decay`; every default moves nothing) declares a memory - consolidation a host runs at rest with `Live::consolidate(at)` (`consolidate_boxed` on a body) or `probbit live control - STRAND rest [--at TIME]`: the window since the last pass (folded in the canonical memory, carried by checkpoints) re-folded - with the rest half-life, counts weighed by the contexts' mean z+, near-duplicate contexts merged, the learned deltas - decayed; one engine-gated `consolidation` line logs the memory's digests before and after, the contexts merged, the weights - moved and the state after. `live verify`, the chain check and both resumes re-run the pass and name the first field of a - forged line; the reflection document gains `consolidation` and one sentence when its window holds such a line. Off by - default: without the key the pass is refused and every document is unchanged. -- **Curiosity** (RFC 0016, docs/rfcs/0016-curiosity.md, docs/persona.md §2.20): an optional `curiosity` block (needs an - expectation block; in the digest) declares a drive fed by the measured surprise: `surprise_recent` (an average of z+ = max(z, 0) with a - half-life in events) and `failed_contexts` (remembered input contexts whose last K visits all missed). Its level, clipped - to a cap at 6 decimals, moves the stance through the engine-owned number input `cur_level`; the stance carries - `drives.curiosity` and the event line a `curiosity` receipt that `live verify` recomputes; the fold rides in the - expectation's checkpoint memory. The optional `stir` gates a self-started idle event: `Live::stir_due(idle_hours)` and - `probbit live control STRAND stir? [--idle-hours H]` (threshold, a rate cap, a slower quiet class while a declared flag - is on); a stir is an ordinary event with `"stir": ""`. The monitor's drives rows show the drive; with a declared stir - the reflection document gains `stirs` and one sentence. Off by default: every document without the block is unchanged. -- **Integrity** (RFC 0014, docs/rfcs/0014-integrity.md, docs/persona.md §2.19 and §5.9): four optional self keys in the - `identity` block (`keep`, `warn`, `cue`, `traits`; outside the persona digest) turn on, per live event, an exact account of - the drift from the declared self: per trait the total variation between the rest-probe odds of the individual rested (the - channels of the hour at the fresh values, what persists as it is) and of the seed's fresh individual, `bound_used` against - RFC 0004's epsilon at the seed, the dial's `used`, the `keep` habits held or not fired, and the persona's own `cue` past - `warn`; every number at 6 decimals. Library `probbit_persona::integrity::{compute, compute_cached, Integrity}` (std and - `nostd`); the rest probe is cached by its program without the comment (the members' key; a row only for an exact answer) and, - while no row is held, by the rested state's body without `turn`. -- **Integrity's cost** (RFC 0014-b): on a body's recorded day the rest probe is answered from the cache 1,156 of 1,160 times - (from 0): +16.1 % of a turn on the host (from +40.1 %), 13,656 B on the `nostd` build per miss (from 20,952 B). Every - integrity number is byte for byte as before. Still over the 2 % / 8 KB budget: a hit builds the probe's program, a miss is a - turn (docs/embedding.md "Integrity on a body"). -- **Integrity on the event line** (engine 0.14.0+): the stance document and the event line carry `integrity`; `live verify` - recomputes it and refuses an edited value naming the line; an engine before 0.14.0 reads such a persona as unsupported - (exit 5). A persona without the self keys writes every strand, stance and corpus byte for byte as before. -- **The monitor's integrity dial and the Mirror's drift table**: the instrument panel's "room left in the bound" placeholder - becomes the integrity dial when the stance carries `integrity`; the Mirror tab shows the per-trait drift, allowance and share - of the event on view. -- **Self-report** (RFC 0013, docs/rfcs/0013-self-report.md, docs/persona.md §5.8 "Self-report"): `probbit reflect STRAND - [--last N | --since-checkpoint] [--json | --text]` and `probbit_persona::reflect` (std and `nostd`): a canonical account of a - window of a strand's events from its lines alone, no solve: the inputs set, the expectation's surprises (z recomputed from the - logged receipts, the 5 largest |z| with predicted vs measured), per trait the levels taken and the odds' drift, the moods' - path and turning points, the drives' peaks, the habits bound, the credit settled, the learned weights' net change and the - control lines (the stance sections read from the checkpoint lines in the window; `null` without one); every number at 6 - decimals, a sha256 digest, 6-12 sentences from fixed templates. Off by default: nothing is written unless asked. -- **Reflection lines** (engine 0.14.0+, docs/persona.md §5.7): `probbit live control STRAND reflect [--last N]` appends the - reflection as a line; `live verify` recomputes it from the lines before it and refuses a forged one naming the line; an - engine before 0.14.0 reads it as unsupported (exit 5). Every strand without one, and every corpus, is byte for byte unchanged. -- **The monitor's Mirror tab**: `/reflection?last=N` and `/s/ID/reflection` serve the reflection computed from the strand file - and the latest logged reflection line with whether it recomputes equal; the page shows the sentences, the top surprises, the - habits bound and the moods at the checkpoint samples. -- **The expectation's members before the turn** (docs/embedding.md "The expectation's members: the order with the reuse, and - what is held"): with the cache reusing the turn's program, every member but A_t is solved before the turn (nothing of the - turn held; `persona::deviation_pre`), its exact marginals held as f64 rows, and A_t's mixture runs after the turn - (`persona::deviation_post`). Every member takes the members' one model, read without any program text (the persona's - couplings, a text without its pairs): a member with other rules in force than E_t's takes it with its own caps - (`run::Members::set_caps`), and the components tier's split is computed once per caps and reused per member - (`probbit_ir::exact_components_split`). Every stance, strand line and digest is the same. Proxy, the body persona: event - heap peak 97,583 → 88,844 B, inside `expect` 97,583 → 55,920 B; recorded day: `expect` 64.1% → 57.5% of the turn, - 0 members on the engine's path (253). -- **The expectation's members, trimmed** (docs/embedding.md "The expectation's members: what the deviation reuses"): - `Live::event` runs the turn first and, with the cache on, the turn hands the deviation its first program's meta and exact - marginals (`persona::turn_at`, `persona::deviation_at`), so the event's own program is compiled and solved once (when no rest - is due, `src` is taken out alike and the op is `decide`; else as before). A member's prep copies only what it steps and - reads, its compile writes no source names, and the members share one model (`run::Members`, the compile pass's folds - replayed per member). `ExpectCache::with_reuse` keeps the old order or checks the reuse. Every stance, strand line and digest - is the same; proxy, the body persona's recorded day: `expect` 67.7% → 63.9% of the turn (-14% per event). The deviation - runs after the turn, so the event's heap peak (the body persona, proxy) is 97,583 B (89,990 before); `with_reuse(false, false)` - keeps the old order (the deviation before the turn: 89,990 B). -- **Resume robustness** (docs/embedding.md "Resume on a body", docs/persona.md section 5.7 "Resume from a record"): - `strand::Replay::open_boxed` checks the header as the chain check does (the persona built on the heap, the header written - again in pieces) and keeps its `Live` boxed, so a replay from the header fits a small stack (proxy: 24,752 → 16,192 B); - `Replay::open` is the same call. `strand::resume_from_record(text, &p, state, clock, &engine)` resumes from a state record - that is behind the strand (a reboot between a line and its record): the event lines after the record's line are applied - (their turn on the logged inputs, digests and bytes checked; not the expectation's member solves) and the individual equals - the unbroken one, byte for byte; refused with the line for a record the strand does not log, a line that differs, another - persona. `strand::resume_boxed_from(header, checkpoint, line, trailing, &p, state, clock, &engine)` resumes from the header, - the last checkpoint line and the lines after it read one at a time, never the whole strand: cost O(lines since the - checkpoint). `turn_trace`: `trace::last_resume()` (lines walked, lines applied, their µs). No byte of any strand changes. -- **The expectation's members as models** (docs/embedding.md "The expectation's members: one program text per event"): with - the cache on, the event's own program is the only text the deviation builds and reads; every other member is compiled - without its text and, when the habits in force are the event's, solved as the event's model with its own `h` - (`run::from_json_parts`, `run::Parts::model_with`, `run::enumerated`: the tier `run` answers with, the same calls); another - tier or other rules: its text, written from its compile meta, run by the engine. Same marginals, so every stance, strand line - and digest is the same as the reference path (`ExpectCache::new(false)`); proxy, the body persona's recorded day: `expect` - 74.6% → 67.7% of the turn. `turn_trace` gains `expect_build_us` and `expect_solve_us` (the members' builds and solves). -- **The expectation's cache** (docs/embedding.md "The expectation's cache"): `Live` keeps the exact marginals of the - programs the deviation solved, keyed by the program without its comment, so an event whose members' programs are the - previous event's solves none of them again (a life of one repeated event: `expect` 52% → 37% of the turn on the proxy, 95% - of its programs from the cache). Every stance, strand line and digest is the same with the cache on or off - (`Live::expect_cache`, `persona::ExpectCache::new(false)`); at most `max_combos + 1` rows; a life whose programs move every - event (most lives: the state moves) backs off and costs about what it cost before. `turn_trace` gains `expect_cached` and - `expect_solved`; `persona::deviation_cached`. -- Development build: the workspace version is `0.14.0-dev` (`probbit --version`, the `engine` of every strand header this - build writes, `verified_by`), so the strands it writes get the 0.14.0 behaviours below (the rounded deviation, the - canonical fold, `expect` on checkpoints, segments of a persona with an expectation block). Strands of 0.13.0 and earlier - replay as their engine wrote them. +0.14.0 makes the persona layer a library crate, `probbit-persona` (std, and `no_std` + `alloc` checked on +`xtensa-esp32s3-none-elf`), and fits it to a small host: heap-returning setup, turn and resume (one event's stack +high-water on the host proxy 31,824 → 13,808 bytes), resume from a checkpoint or from a record behind the strand, and sealed +strand segments. The expectation keeps an exact cache and solves the event's own program once. The self layer arrives off by +default (RFCs 0013–0016): a reflection computed from a strand's lines alone, an integrity account of the drift from the declared +self, a rest pass over the expectation's memory and a curiosity drive with an optional stir; a declared, replayable self; it +does not feel. A persona that declares none of its keys gets none of its fields or lines. The monitor gains the Mirror tab, the +integrity dial and life-in-segments serving; every CLI output of the library move is byte for byte as 0.13.0. + +Engine gate: strands with the new line kinds (`reflection`, `consolidation`) or the new keys (`integrity`, `curiosity`, a +checkpoint's `expect`, `expectation.rest`, the `identity` self keys) read as unsupported, exit 5, on 0.13.x. + +### The persona layer as a library crate (std, no_std) + - **`probbit-persona`, the document layer and the strand writer as a library crate** (docs/embedding.md): `json`, `yaml`, `persona`, `run`, `router` and `evaluate` (feature `decide`), `sys`, and `strand` (the pure half of `probbit live`: `Live`, the line writers, `Replay`, `verify*`, with no file I/O) move out of probbit-cli, which uses them unchanged; every CLI output is byte for byte as 0.13.0. probbit-core, probbit-ir, probbit-decide and probbit-persona gain the features `std` (default) and `nostd` (`no_std` + `alloc`, through the new `probbit-nostd` facade), checked on `xtensa-esp32s3-none-elf`; probbit-wasm depends on the crate instead of including the CLI's files. +- Development build: the workspace version is `0.14.0-dev` (`probbit --version`, the `engine` of every strand header this + build writes, `verified_by`), so the strands it writes get the 0.14.0 behaviours below (the rounded deviation, the + canonical fold, `expect` on checkpoints, segments of a persona with an expectation block). Strands of 0.13.0 and earlier + replay as their engine wrote them. - **Cross-platform parity of the deviation** (docs/persona.md section 2.17, docs/embedding.md): the stance's `deviation` (`self`, `pred`, `input`) is rounded to 6 decimals like every other number of a stance, so a checkpoint line has the same bytes on every libm (a `nostd` build's `libm` crate differed from the host's by 2 ULP, which broke the chain at the second checkpoint). Gated on the strand header's engine: a strand written before 0.14.0 keeps its full-precision deviation, and `live verify` accepts its checkpoint when it equals the replay's at 6 decimals; strands without an expectation block are unchanged. +- **`turn_trace`** (feature of probbit-persona and probbit-cli, off by default): `persona::trace` records the last event's + microseconds and a host probe's value per phase (input, expect, prep, compile, solve, decode, document, line, fold), so a + firmware measures the same breakdown on its hardware; `probbit live` in such a build prints it on stderr when + `PROBBIT_TURN_TRACE` is set. + +### Small hosts: boxed setup, checkpoint resume, strand segments + - **The setup path fits a small stack** (docs/embedding.md "Stack budget"): heap-returning setup calls `persona::build_boxed` (`Box`), `persona::init_boxed` (`Box`), `Live::start_boxed` (`Box` and the header), `Live::start_to` (the header handed to the host in fixed-size pieces, so a firmware can write it to flash piece by piece with @@ -125,6 +55,16 @@ All notable changes to this project are documented here. The format follows the same way, and `resume` opens the strand's header once (the chain check builds the persona on the heap and compares the header in pieces, without a replay). On the host proxy one event's stack high-water fell from 31,824 to 13,808 bytes and `resume`'s from 30,560 to 15,792; every stance document, strand line and digest is byte for byte as before. +- **Resume robustness** (docs/embedding.md "Resume on a body", docs/persona.md section 5.7 "Resume from a record"): + `strand::Replay::open_boxed` checks the header as the chain check does (the persona built on the heap, the header written + again in pieces) and keeps its `Live` boxed, so a replay from the header fits a small stack (proxy: 24,752 → 16,192 B); + `Replay::open` is the same call. `strand::resume_from_record(text, &p, state, clock, &engine)` resumes from a state record + that is behind the strand (a reboot between a line and its record): the event lines after the record's line are applied + (their turn on the logged inputs, digests and bytes checked; not the expectation's member solves) and the individual equals + the unbroken one, byte for byte; refused with the line for a record the strand does not log, a line that differs, another + persona. `strand::resume_boxed_from(header, checkpoint, line, trailing, &p, state, clock, &engine)` resumes from the header, + the last checkpoint line and the lines after it read one at a time, never the whole strand: cost O(lines since the + checkpoint). `turn_trace`: `trace::last_resume()` (lines walked, lines applied, their µs). No byte of any strand changes. - **Resume after a checkpoint with an expectation block**: the chain check that `resume` (and `probbit live ... --strand`) runs validates each checkpoint line (its state read, its event count, its link to the event before) without opening it as a replay start, which a persona with an `expectation` block cannot do; such a strand resumes after its first checkpoint (it exited 2 @@ -145,15 +85,101 @@ All notable changes to this project are documented here. The format follows replays the newest segment, refuses unless its last line is an event line (exit 2, nothing written), appends the sealing checkpoint, writes the next segment file and prints it (`file`). `probbit live ... --strand NAME.strand` now always continues the newest segment that exists, with or without `--segment-every`, so a sealed file is never appended to. + +### The expectation cache and the members + +- **The expectation's cache** (docs/embedding.md "The expectation's cache"): `Live` keeps the exact marginals of the + programs the deviation solved, keyed by the program without its comment, so an event whose members' programs are the + previous event's solves none of them again (a life of one repeated event: `expect` 52% → 37% of the turn on the proxy, 95% + of its programs from the cache). Every stance, strand line and digest is the same with the cache on or off + (`Live::expect_cache`, `persona::ExpectCache::new(false)`); at most `max_combos + 1` rows; a life whose programs move every + event (most lives: the state moves) backs off and costs about what it cost before. `turn_trace` gains `expect_cached` and + `expect_solved`; `persona::deviation_cached`. +- **The expectation's members as models** (docs/embedding.md "The expectation's members: one program text per event"): with + the cache on, the event's own program is the only text the deviation builds and reads; every other member is compiled + without its text and, when the habits in force are the event's, solved as the event's model with its own `h` + (`run::from_json_parts`, `run::Parts::model_with`, `run::enumerated`: the tier `run` answers with, the same calls); another + tier or other rules: its text, written from its compile meta, run by the engine. Same marginals, so every stance, strand line + and digest is the same as the reference path (`ExpectCache::new(false)`); proxy, the body persona's recorded day: `expect` + 74.6% → 67.7% of the turn. `turn_trace` gains `expect_build_us` and `expect_solve_us` (the members' builds and solves). +- **The expectation's members, trimmed** (docs/embedding.md "The expectation's members: what the deviation reuses"): + `Live::event` runs the turn first and, with the cache on, the turn hands the deviation its first program's meta and exact + marginals (`persona::turn_at`, `persona::deviation_at`), so the event's own program is compiled and solved once (when no rest + is due, `src` is taken out alike and the op is `decide`; else as before). A member's prep copies only what it steps and + reads, its compile writes no source names, and the members share one model (`run::Members`, the compile pass's folds + replayed per member). `ExpectCache::with_reuse` keeps the old order or checks the reuse. Every stance, strand line and digest + is the same; proxy, the body persona's recorded day: `expect` 67.7% → 63.9% of the turn (-14% per event). The deviation + runs after the turn, so the event's heap peak (the body persona, proxy) is 97,583 B (89,990 before); `with_reuse(false, false)` + keeps the old order (the deviation before the turn: 89,990 B). +- **The expectation's members before the turn** (docs/embedding.md "The expectation's members: the order with the reuse, and + what is held"): with the cache reusing the turn's program, every member but A_t is solved before the turn (nothing of the + turn held; `persona::deviation_pre`), its exact marginals held as f64 rows, and A_t's mixture runs after the turn + (`persona::deviation_post`). Every member takes the members' one model, read without any program text (the persona's + couplings, a text without its pairs): a member with other rules in force than E_t's takes it with its own caps + (`run::Members::set_caps`), and the components tier's split is computed once per caps and reused per member + (`probbit_ir::exact_components_split`). Every stance, strand line and digest is the same. Proxy, the body persona: event + heap peak 97,583 → 88,844 B, inside `expect` 97,583 → 55,920 B; recorded day: `expect` 64.1% → 57.5% of the turn, + 0 members on the engine's path (253). + +### The self layer (RFCs 0013–0016, off by default) + +- **Self-report** (RFC 0013, docs/rfcs/0013-self-report.md, docs/persona.md §5.8 "Self-report"): `probbit reflect STRAND + [--last N | --since-checkpoint] [--json | --text]` and `probbit_persona::reflect` (std and `nostd`): a canonical account of a + window of a strand's events from its lines alone, no solve: the inputs set, the expectation's surprises (z recomputed from the + logged receipts, the 5 largest |z| with predicted vs measured), per trait the levels taken and the odds' drift, the moods' + path and turning points, the drives' peaks, the habits bound, the credit settled, the learned weights' net change and the + control lines (the stance sections read from the checkpoint lines in the window; `null` without one); every number at 6 + decimals, a sha256 digest, 6-12 sentences from fixed templates. Off by default: nothing is written unless asked. +- **Reflection lines** (engine 0.14.0+, docs/persona.md §5.7): `probbit live control STRAND reflect [--last N]` appends the + reflection as a line; `live verify` recomputes it from the lines before it and refuses a forged one naming the line; an + engine before 0.14.0 reads it as unsupported (exit 5). Every strand without one, and every corpus, is byte for byte unchanged. +- **Integrity** (RFC 0014, docs/rfcs/0014-integrity.md, docs/persona.md §2.19 and §5.9): four optional self keys in the + `identity` block (`keep`, `warn`, `cue`, `traits`; outside the persona digest) turn on, per live event, an exact account of + the drift from the declared self: per trait the total variation between the rest-probe odds of the individual rested (the + channels of the hour at the fresh values, what persists as it is) and of the seed's fresh individual, `bound_used` against + RFC 0004's epsilon at the seed, the dial's `used`, the `keep` habits held or not fired, and the persona's own `cue` past + `warn`; every number at 6 decimals. Library `probbit_persona::integrity::{compute, compute_cached, Integrity}` (std and + `nostd`); the rest probe is cached by its program without the comment (the members' key; a row only for an exact answer) and, + while no row is held, by the rested state's body without `turn`. +- **Integrity on the event line** (engine 0.14.0+): the stance document and the event line carry `integrity`; `live verify` + recomputes it and refuses an edited value naming the line; an engine before 0.14.0 reads such a persona as unsupported + (exit 5). A persona without the self keys writes every strand, stance and corpus byte for byte as before. +- **Integrity's cost** (RFC 0014-b): on a body's recorded day the rest probe is answered from the cache 1,156 of 1,160 times + (from 0): +16.1 % of a turn on the host (from +40.1 %), 13,656 B on the `nostd` build per miss (from 20,952 B). Every + integrity number is byte for byte as before. Still over the 2 % / 8 KB budget: a hit builds the probe's program, a miss is a + turn (docs/embedding.md "Integrity on a body"). +- **Rest pass** (RFC 0015, docs/rfcs/0015-consolidation.md, docs/persona.md §2.17): an optional `rest` key of the + `expectation` block (`half_life`, `bucket`, `surprise_gain`, `decay`; every default moves nothing) declares a memory + consolidation a host runs at rest with `Live::consolidate(at)` (`consolidate_boxed` on a body) or `probbit live control + STRAND rest [--at TIME]`: the window since the last pass (folded in the canonical memory, carried by checkpoints) re-folded + with the rest half-life, counts weighed by the contexts' mean z+, near-duplicate contexts merged, the learned deltas + decayed; one engine-gated `consolidation` line logs the memory's digests before and after, the contexts merged, the weights + moved and the state after. `live verify`, the chain check and both resumes re-run the pass and name the first field of a + forged line; the reflection document gains `consolidation` and one sentence when its window holds such a line. Off by + default: without the key the pass is refused and every document is unchanged. +- **Curiosity** (RFC 0016, docs/rfcs/0016-curiosity.md, docs/persona.md §2.20): an optional `curiosity` block (needs an + expectation block; in the digest) declares a drive fed by the measured surprise: `surprise_recent` (an average of z+ = max(z, 0) with a + half-life in events) and `failed_contexts` (remembered input contexts whose last K visits all missed). Its level, clipped + to a cap at 6 decimals, moves the stance through the engine-owned number input `cur_level`; the stance carries + `drives.curiosity` and the event line a `curiosity` receipt that `live verify` recomputes; the fold rides in the + expectation's checkpoint memory. The optional `stir` gates a self-started idle event: `Live::stir_due(idle_hours)` and + `probbit live control STRAND stir? [--idle-hours H]` (threshold, a rate cap, a slower quiet class while a declared flag + is on); a stir is an ordinary event with `"stir": ""`. The monitor's drives rows show the drive; with a declared stir + the reflection document gains `stirs` and one sentence. Off by default: every document without the block is unchanged. + +### Monitor + +- **The monitor's Mirror tab**: `/reflection?last=N` and `/s/ID/reflection` serve the reflection computed from the strand file + and the latest logged reflection line with whether it recomputes equal; the page shows the sentences, the top surprises, the + habits bound and the moods at the checkpoint samples. +- **The monitor's integrity dial and the Mirror's drift table**: the instrument panel's "room left in the bound" placeholder + becomes the integrity dial when the stance carries `integrity`; the Mirror tab shows the per-trait drift, allowance and share + of the event on view. - **`probbit monitor --dir` serves a life in segments as one strand**: `NAME.strand` and `NAME.0001.strand`, ... (without a gap) are listed once, id `NAME`, with the life's event count, the newest file's `segment` index and the base name's sidecar; the page follows the newest file and goes on in the next one within the 1 s scan after a seal, its event count carrying on. -- **`turn_trace`** (feature of probbit-persona and probbit-cli, off by default): `persona::trace` records the last event's - microseconds and a host probe's value per phase (input, expect, prep, compile, solve, decode, document, line, fold), so a - firmware measures the same breakdown on its hardware; `probbit live` in such a build prints it on stderr when - `PROBBIT_TURN_TRACE` is set. -### Fixed +### Fixes - The test of the served demo's restart allows 45 s for its paced loop (about 13 s of sleeps): on a virtual machine whose sleeps overshoot up to 2.5 times it failed at 20 s, on this tree and the one before (the replay itself takes about 30 ms). @@ -169,7 +195,6 @@ All notable changes to this project are documented here. The format follows npm): `docs/monitor-app.md` says to remove the quarantine mark before the first open and how to recover after a refused one (move the app, or Open Anyway), and that the bundled probbit serves several strands with a switcher; the plugin README names the second warning an older probbit gives; REPRODUCE.md no longer says only 0.12.0 replays the strand. - ## 0.13.0 - 2026-10-26 The monitor face and the Mac app, identity labels and many strands in one monitor, and the OpenClaw plugin 0.2.0. A persona From de7c63096fd1c380d340a3f916a1c3f30f648238 Mon Sep 17 00:00:00 2001 From: BitmapAsset Date: Thu, 29 Oct 2026 05:03:19 -0700 Subject: [PATCH 61/61] RFC 0011: a simulated amplitude register as an optional solver tier, off by default (#82) Rounds 1-7 of RFC 0011 (research/rfc0011-amplitude-register, kept with its per-round history): an exact simulated amplitude register on a CPU as an optional solver tier, off by default; engine: {tier: amplitude} is the opt-in. Round 7: one math path on the std and the no_std build (libm); the landing renormalised once before standard iterations, a non-finite mass declines. Measured on x86_64: replay 100/100 lives on each of the three schedules on the std and the no_std build; declined programs 0 of 165; tier-off corpora byte-identical (P0 653 outputs 0 differ, strands-A 726/726); long sweep 1.50x the default per event, no_std stack at most 2,944 B. Not measured: arm64 replay of the tier. A register strand recorded on a 0.14.0-dev build before round 7 may not verify after it (the default schedule's digests moved on one lab persona; no shipped fixture moved). Numbers and the round log in docs/rfc/0011 and the CHANGELOG Research bullets. --- CHANGELOG.md | 69 ++++ Cargo.lock | 1 + docs/persona.md | 30 +- docs/rfc/0011-amplitude-register.md | 291 +++++++++++++++ docs/rfc/0011-demo-register.png | Bin 0 -> 176133 bytes docs/rfc/0011-demo-thermal.png | Bin 0 -> 175737 bytes examples/persona/demo/README.md | 51 +++ examples/persona/demo/life.jsonl | 17 + examples/persona/demo/register.strand | 18 + examples/persona/demo/register.yaml | 78 ++++ examples/persona/demo/thermal.strand | 18 + examples/persona/demo/thermal.yaml | 71 ++++ probbit-decide/Cargo.toml | 2 + probbit-decide/src/amp.rs | 506 ++++++++++++++++++++++++++ probbit-decide/src/lib.rs | 1 + probbit-nostd/src/lib.rs | 3 + probbit-persona/src/persona.rs | 136 ++++++- probbit-persona/src/run.rs | 91 +++++ 18 files changed, 1375 insertions(+), 8 deletions(-) create mode 100644 docs/rfc/0011-amplitude-register.md create mode 100644 docs/rfc/0011-demo-register.png create mode 100644 docs/rfc/0011-demo-thermal.png create mode 100644 examples/persona/demo/README.md create mode 100644 examples/persona/demo/life.jsonl create mode 100644 examples/persona/demo/register.strand create mode 100644 examples/persona/demo/register.yaml create mode 100644 examples/persona/demo/thermal.strand create mode 100644 examples/persona/demo/thermal.yaml create mode 100644 probbit-decide/src/amp.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d8a3ab9..71c326a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -195,6 +195,75 @@ checkpoint's `expect`, `expectation.rest`, the `identity` self keys) read as uns npm): `docs/monitor-app.md` says to remove the quarantine mark before the first open and how to recover after a refused one (move the app, or Open Anyway), and that the bundled probbit serves several strands with a switcher; the plugin README names the second warning an older probbit gives; REPRODUCE.md no longer says only 0.12.0 replays the strand. +### Research + +- **RFC 0011, round 7: one math path for the register** (research, `engine.tier: amplitude` stays opt-in; docs/rfc/0011-amplitude-register.md, + "One math path"). The simulated register evaluates cos, sin, sqrt, asin, acos, atan2 and floor through the `libm` crate on the std and + the no_std build alike (a direct dependency of probbit-decide). 100 fresh lives per schedule recorded on the std build re-verify on + the no_std build 100/100 for `tf-linear-16x4`, `tf-linear-64x16` (round 5: 0/100) and `qaoa-p2` (98/100), x86_64; the tutor's + default-schedule strands are byte-identical to before (100/100) and both demo strands re-record byte for byte (a 52-qubit + recorded day's default strands moved in the last bits). Cost on that day: long sweep 1.50x the default. Exact, on a CPU. +- **RFC 0011, round 7: the amplification lands on the long sweep** (research). `amplify_exact` renormalises the swept state once + before the standard iterations: the diffusion about a state of squared norm 1 + eps is not unitary, so the shortfall grew with the + iterations and 165 corpus programs declined under the long sweep; now 0 decline under any schedule (median gap 0.151709, agreement + 0.981671 on the corpus). The receipt's mass before is unchanged; a mass that is not a number declines instead of panicking. + Simulated, exact, on a CPU. +- **RFC 0011, round 7: docs** (research). docs/persona.md's `engine.schedule` paragraph and the demo README state the measured + replay on both builds; docs/persona.md section 5.8: the stance digest is the sha256 of `/doc/N`'s JSON without the trailing newline. +- **RFC 0011, round 6: the demo** (research; `examples/persona/demo/`, docs/rfc/0011-amplitude-register.md, "The demo"). A + fictional tutor reduced to its humour component, with praise and criticism pulling humour in opposite directions, in two + copies: `register.yaml` (`engine: {tier: amplitude, amplify: true, schedule: tf-linear-64x16}`) and `thermal.yaml` (no engine + block). One 17-event life recorded on each (both verify, twice byte for byte, 0 floats past 6 decimals). At event 9 (line 10 + of both strands, the same program) the thermal odds of the top two humour levels sit 0.007412 apart and the simulated + register separates them by 0.494808; the Mood Monitor's two faces at that event are in the RFC. On the corpus's own program + `a7fae99b114ea2d2`: thermal gap 0.010516, long sweep 0.951467. The long sweep replays on the recording build only; the + default schedule stays for every recorded strand. No engine change. Simulated, exact, on a CPU. +- **RFC 0011, round 5: schedules and the indifferent set** (research, `engine.schedule`, absent = the default sweep; + docs/rfc/0011-amplitude-register.md, "Schedules and the indifferent set"). Three names: `tf-linear-16x4` (default), + `tf-linear-64x16` (four times the layers), `qaoa-p2` (two layers, angles fixed on a held-out third of the corpus). On the 10.7 % + of the recorded corpus's trait decisions where the thermal tier is indifferent, the simulated register's top-two gap has median + 0.049 (default), 0.152 (long sweep), 0.094 (qaoa-p2); agreement elsewhere 97.6 / 98.2 / 93.0 %; noise 0.0007. The long sweep + is the rule's choice (1.5× the solve per event on a recorded day). A missed amplification landing (165 programs under the long sweep) now declines to the + default tiers instead of panicking. Failure, stated: the no_std build re-verifies the long sweep's strands 0/100 (qaoa-p2 98/100; + the default 100/100): last-bit math differences, so non-default schedules replay only on the recording build for now. Without + the key every stance and strand is byte-identical to round 4. Exact, on a CPU. +- **RFC 0011, round 4: exact amplitude amplification in place of the projection** (research, `engine.amplify: true`, off by + default; docs/rfc/0011-amplitude-register.md, "Amplification"). `amp::amplify_exact`: the rules as the oracle, the diffusion + about the swept state, k standard iterations and one phase-matched final one, so the simulated register lands on its + feasible plans exactly (mass ≥ 1 − 1e-12, asserted). The plane argument says the result is the projection's distribution; + measured: marginals equal to 1.1e-12 on 220 programs, the same plan on 220/220 and on all 339,389 solves of the recorded corpus (B4 unchanged, 96.29 %), + 287/287 stance lines on a recorded day; + cost 1.02× the projected solve. The receipt adds `engine.amplify` and `feasible_mass` becomes 1. Without the key every stance + and strand is byte-identical to round 3. Simulated exactly on a CPU; no quantum hardware, no speedup claim. +- **RFC 0011, round 3: the verdict, KEEP as an optional tier** (research, off by default; docs/rfc/0011-amplitude-register.md, + status "accepted as optional tier"). Measured on every program the recorded corpus's 653 outputs solve (338,567 exact solves, + 0 declined, the corpus byte-identical with the comparison beside it): the exact simulated register picks the same level as the + exact default tier in 2,095,627 of 2,176,371 trait decisions (96.29 %, bar 90 %); the differing cases sit where the default + tier's own level holds less than half the mass (91 % of them), not where the rules' projection does the work. Three designed + two-path programs differ from the thermal odds by 0.25-0.27 at the node (engine vs reference 1.4e-14). The bound harness with + the tier: 0 of 4,100 cases above a certificate computed with the tier, 1,598 above one computed without it, so `persona bound` + must run with the tier on (docs/persona.md says so). A rule-penalty term was measured and not adopted (it lifts the Born mass + on rule-abiding plans and drops agreement to 83 %). No code change in this round. Simulated exactly on a CPU; no quantum + hardware, no speedup claim. +- **RFC 0011, round 2: multi-level traits on the amplitude register** (draft, research; docs/rfc/0011-amplitude-register.md): + a variable with d allowed values takes ⌈log2 d⌉ qubits (`amp::Encoding`; a three-level trait two qubits, its fourth code + unused), the start is the uniform superposition over the valid codes, the mixer is the complete-graph mixer per variable + (`amp::MIXER`, the identity on unused codes; exp(i t X) for two values, round 1's mixer exactly), and the cost is one general + diagonal phase layer exp(−iγE), E = −log w per basis state (`Register::phase`), so pair tables between three-level traits + need no decomposition (round 1's Walsh transform and its decline are gone). The unused codes keep amplitude 0, checked after + every layer, never renormalised. The tier runs one register per connected component of the program (exact: the state is the + product of the components' states); `engine.q_max` bounds the largest component's encoded qubits. New receipt fields + `engine.components`, `encoding`, `mixer`; `qubits` counts encoded qubits. Measured against an independent mixed-radix + reference: max |Δ marginal| 2.7e-13 on 220 programs. Simulated exactly on a CPU; no quantum hardware, no speedup claim. +- **RFC 0011, round 1: an amplitude register** (draft; docs/rfc/0011-amplitude-register.md): `probbit-decide::amp`, an exact + simulated qubit register on a CPU (q ≤ 16 on the host; 16 · 2^q bytes of amplitudes on the heap; std and no_std + alloc): + gates H, X, Z, RX, RZ, CNOT, CZ, RZZ, a Trotterised transverse-field sweep, QAOA layers, exact marginals, Born-rule + measurement with the engine's RNG, a sha256 digest of the state at 6 decimals. The persona flag `engine.tier: amplitude` + (off by default, `engine.q_max` default 12) solves a turn's program on it when every variable has at most two values; + otherwise the default tiers answer and the stance document says why. Receipt fields `engine.qubits`, `circuit`, + `schedule`, `schedule_name`, `state`, `feasible_mass`. Without the flag no byte of any stance or strand changes. No quantum + hardware, no speedup claim; the comparison with the default tier is the next round. + ## 0.13.0 - 2026-10-26 The monitor face and the Mac app, identity labels and many strands in one monitor, and the OpenClaw plugin 0.2.0. A persona diff --git a/Cargo.lock b/Cargo.lock index 4d8aba5..7b2e5f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,6 +59,7 @@ dependencies = [ name = "probbit-decide" version = "0.14.0-dev" dependencies = [ + "libm", "probbit-core", "probbit-ir", "probbit-nostd", diff --git a/docs/persona.md b/docs/persona.md index e85e753..4040554 100644 --- a/docs/persona.md +++ b/docs/persona.md @@ -230,12 +230,40 @@ engine: 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) + # tier: amplitude # research (RFC 0011, draft): an exact simulated qubit register on a CPU; absent = the default tiers + # q_max: 12 # the most encoded qubits (ceil(log2 d) per variable) in one component the amplitude tier takes (1-16) + # amplify: false # research (RFC 0011, round 4): with the tier, exact amplitude amplification in place of the projection + # schedule: tf-linear-16x4 # research (RFC 0011, round 5): the register's evolution: tf-linear-16x4 | tf-linear-64x16 | qaoa-p2 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: " ``` +**`engine.tier: amplitude` (research, off by default; docs/rfc/0011-amplitude-register.md).** The turn's program is solved by +an exact simulated qubit register on a CPU: a variable with d allowed values takes ⌈log2 d⌉ qubits (a three-level trait two, +its fourth code unused and kept at amplitude 0), one register per connected component of the program (2^q complex +amplitudes each), a fixed transverse-field sweep with a general diagonal phase layer, the habits as a projection onto the +plans that obey them, when the largest component has at most `q_max` encoded qubits; otherwise the default tiers answer and +`engine.amplitude` says why (`"declined: ..."`). With the tier, the stance document's `engine` carries `tier: "amplitude"`, +`qubits`, `components`, `encoding`, `mixer`, `circuit`, `schedule`, `schedule_name`, `state` (sha256 digests of the encoding, +the layer list, the schedule and the final amplitudes at 6 decimals) and +`feasible_mass`; `live verify` recomputes them from the strand. Without the key nothing changes, byte for byte. An engine +without the tier reports a strand that uses it as `unsupported by this engine: engine.tier` (exit 5). `engine.amplify: true` +(research, round 4; needs the tier) replaces the projection by exact amplitude amplification, simulated on a CPU: the +rules are the oracle, the register is rotated onto the plans that obey them, the stance is the projection's (the same plan and odds, measured equal to +1.1e-12) and `engine.amplify` logs the iterations, the final phases and the mass before and after (`feasible_mass` 1). `engine.schedule` +(research, round 5; needs the tier; absent = `tf-linear-16x4`) names the evolution of the register, simulated exactly on a CPU: `tf-linear-64x16` (four times +the layers) separates the top two levels where the thermal tier is a near tie (median gap 0.152 vs 0.049 on the recorded corpus) and +agrees more elsewhere; `qaoa-p2` is two fixed QAOA layers (RFC 0011, "Schedules and the indifferent set"). Since round 7 the +register computes its math functions with one library (`libm`) on the std and the no_std build: 100 fresh lives per schedule +recorded on the std build re-verify on the no_std build 100 of 100 and re-write byte for byte, on each of the three schedules +(x86_64; arm64 not measured). The math change is in the last bits: the tutor's and the demo's default-schedule strands are +byte-identical to before, a larger persona's can move. The register is a +simulation: no quantum hardware, no speedup claim; its stances agree with the default tier in 96 % of trait decisions on the +recorded corpus (RFC 0011, round 3). With the key, `persona bound` probes through the tier: compute the certificate again with +it, since one computed without the key does not cover the tier. + ### 2.8 Learning (optional) ```yaml @@ -1824,7 +1852,7 @@ cannot read the documents: |---|---| | `/` | the page: one file embedded in the binary (under 72 KB, styles and script inline; it fetches no fonts, styles or scripts), with a Content-Security-Policy that lets it connect to its own server and nowhere else | | `/events` | server-sent events: `meta` (the layout: the strand, the engine, the persona, its traits, moods, inputs and history features in the persona's order, and `face`, the two moods the face's meters show), the latest `frame`, then a frame per event: `{n, doc, given, learned, spark, diverges, note}` (with `engine.trajectory` also `trajectory` and `settle`, below), that is the stance document, the inputs as the strand logs them, the learned deltas, the moods' recent positions, the line that differs and a warning; a heartbeat comment after 15 quiet seconds; events that land within one poll (100 ms), or while a page falls behind, come as the latest frame | -| `/doc/N` | event N's stance document, canonical JSON and a newline, as `probbit live` printed it (its sha256 is the `stance` digest of the strand's line N + 1); the latest 10,000 are kept | +| `/doc/N` | event N's stance document, canonical JSON and a newline, as `probbit live` printed it (the sha256 of the JSON without that newline is the `stance` digest of the strand's line N + 1); the latest 10,000 are kept | | `/strands` | the strands served, canonical JSON: `{demo, dir, strands: [{id, strand, labels, persona, n, glyph, age_s, labels_error?, segment?}]}` (the file name, never its directory; `age_s`: seconds since the strand file was last written, a life in segments: its newest file; `segment`: that file's index, when it is not the first); the demo lists none | | `/s/ID/events`, `/s/ID/doc/N` | as `/events` and `/doc/N`, for strand ID; `/events` and `/doc/N` are the first strand's, so a single-strand page is as before | diff --git a/docs/rfc/0011-amplitude-register.md b/docs/rfc/0011-amplitude-register.md new file mode 100644 index 0000000..5d26c24 --- /dev/null +++ b/docs/rfc/0011-amplitude-register.md @@ -0,0 +1,291 @@ +# RFC 0011: an amplitude register, an exact simulated qubit register as an optional solver tier for small stance programs + +Status: **accepted as optional tier (research; off by default)**, round 3. Verdict by the registered rule (B4 ≥ 90 %): 96.29 % +of 2,176,371 trait decisions on the recorded corpus agree with the exact default tier; B5 passes (three designed programs differ +from the thermal odds by 0.25-0.27 at the node; 0 of 4,100 bound cases exceed a certificate computed with the tier). Nothing in +this RFC changes a release or `main`; the tier stays off unless a persona asks for it. Turning it on changes the bound +certificate: compute it again with the tier (section 5). Round 4 adds `engine.amplify` (off by default): exact amplitude +amplification in place of the projection, which lands on the projection's numbers with feasible mass 1 (section 5, +"Amplification"). Round 5 adds `engine.schedule` (absent = the default sweep): a longer sweep separates the top two levels +where the thermal tier is indifferent and agrees more elsewhere (section 5, "Schedules and the indifferent set"). Round 6 shows +one such decision end to end, with a demo persona, its two recorded strands and the monitor's two faces (section 5, "The demo"). + +## 1. What it is, in one paragraph + +A register of q qubits (q ≤ 16 on the host, q ≤ 12 on a small microcontroller) simulated **exactly** on a CPU as a state +vector of 2^q complex amplitudes: unitary gates with phase, interference between basis states, measurement by the Born rule, +every step logged so the strand replays byte for byte. It is a simulation of a tiny quantum system and nothing else: no +quantum hardware, no speedup claim, no scaling past what 2^q amplitudes allow (10 qubits 16 KB, 16 qubits 1 MB, 20 qubits +16 MB; a microcontroller with 8 MB of PSRAM reaches about 16; a laptop about 25-28). Sampling a thermal distribution cannot +produce this behaviour (amplitudes carry signs and phases that probabilities do not); the register gets it by paying 2^q +memory and time, which is fine for the programs probbit actually solves (tens of two-valued variables, most of them not +coupled all at once). + +## 2. Why bother (the question the measurement answers) + +probbit's stance is one exact program over coupled p-bits per turn: biases and couplings → exact marginals → a level per +trait with its odds → a stance line, habits as hard rules on top. The p-bit tier gives a thermal (Boltzmann) distribution. +An amplitude register fed the same program as a Hamiltonian gives a **coherent** distribution: options can cancel (destructive +interference) as well as add, and a short unitary evolution (a transverse-field sweep or a few QAOA-style layers) reaches its +distribution by dynamics, not by equilibrium. Two concrete things that might matter to an agent: + +1. **Conflict handling.** When two drives push a trait opposite ways, the thermal answer is a flattened mixture; a coherent + answer can be a sharpened choice or a cancellation. Whether that is better for a stance is exactly what has to be measured. +2. **Reflex arcs.** A sub-turn surge needs a cheap, deterministic, bounded state shift. A q-qubit register evolved by a fixed + small circuit is O(2^q · gates) with no sampling noise and no convergence question, which is a candidate mechanism for the + arc, to be compared with a rule-based register. + +It might also matter to nobody: the register may reproduce the p-bit marginals within noise, in which case it is a toy and the +RFC is closed with that number. Both outcomes are publishable with receipts. + +## 3. Design (minimal) + +- Module `probbit-decide::amp` (std, and no_std with `alloc`; the state vector on the heap): `Register::new(q)`, gates + `H, X, Z, RX(θ), RZ(θ), CNOT, CZ, RZZ(θ)` (enough for Ising-type programs and QAOA layers), `evolve_ising(program, + schedule)` (a Trotterised transverse-field sweep, fixed step count), `qaoa(program, p, params)`, `marginals()` (exact, no + sampling), `measure(seed)` (the Born rule with the engine's deterministic RNG), `digest()` (sha256 of the canonical state at + 6 decimals, like every other emitted float). Amplitudes are two f64: 16 · 2^q bytes exactly. +- A solver tier `amplitude` beside `components` / `knapsack` / `exact`: selected by the persona flag `engine.tier: amplitude` + (off when absent) and only when every variable has an allowed value and the largest connected component of the program + has at most `engine.q_max` encoded qubits (default 12, 1-16); otherwise the existing tiers run and the stance document says + why (`engine.amplitude: "declined: ..."`). +- How a program becomes registers (round 2). **Encoding:** a variable with d candidate values (allowed, and the clamp) + takes ⌈log2 d⌉ qubits; code c < d is its c-th value, the codes ≥ d are unused (d = 3: codes 00, 01, 10; 11 unused). The start + is the uniform superposition over the valid codes (for two-valued variables, H on every qubit). **Mixer** (`complete-graph`, + fixed): per variable exp(i t A_d) with A_d = J − I, the adjacency of the complete graph on its d codes, and the identity on + the unused codes; closed form e^{−it}(I + (e^{itd} − 1)/d · J), O(d) per block; for d = 2 it is exp(i t X), round 1's mixer + exactly. **Phase layer:** one general diagonal layer, a_x ← e^{−iγE(x)} a_x with E = −log w of the plan x encodes, on every + valid basis state (O(2^q) per layer). Pair tables between multi-level variables give 3- and 4-body terms in any qubit + encoding; the diagonal layer takes them as they are, with no Walsh transform, no residual and no decline. Round 1's RZ/RZZ + decomposition of the cost is gone: on a 16-variable two-valued chain one general phase layer took 333 µs against 1,938 µs + for the RZ/RZZ layer, and the whole sweep 48.4 ms against 51.9 ms (x86_64, median of 20); the gates stay in the module. The unused codes keep amplitude exactly 0: the + engine checks their mass (≤ 1e-15) after every layer and never renormalises. **Components:** the program is split into its + connected components (two variables are linked by a pair or by sharing a capacity rule, as the `components` tier splits + it) and each component runs its own register. This is exact: E is a sum over components and the mixer acts per variable, + so the joint state is the product of the components' states (the repository's example personas need 52-70 encoded qubits + as one register, 10-15 in their largest component). The sweep is the fixed default `tf-linear-16x4` (16 Trotter steps, + total time 4, `H(s) = −(1−s) Σ A_i + s E`). The rules (habits, caps, clamps) are a projection per component: the Born + probabilities of the plans that break one are set to 0 and the rest renormalised, so a stance obeys every habit in force + exactly as on the other tiers. Plan = per component its most probable feasible plan. +- Receipt fields in the stance document: `engine.tier = "amplitude"`, `engine.qubits` (encoded qubits, summed over the + components), `engine.components` (each component's qubits), `engine.encoding` (digest of the variable → qubits map with each + variable's number of valid codes), `engine.mixer` (`complete-graph`), `engine.circuit` (digest of the layer list), + `engine.schedule` (digest) and `engine.schedule_name`, `engine.state` (digest of the final amplitudes at 6 decimals), + `engine.feasible_mass` (the Born mass on plans that obey the rules), and the marginals at 6 decimals (the stance odds). + `live verify` recomputes all of it from the strand alone (the same rule as every other tier). An engine without the tier + reads the persona's `engine.tier` as a field it does not know: `unsupported by this engine` (exit 5), never a divergence. +- No change to habits (hard rules), the strand format for other tiers, or any public wording. The bound certificate is + computed from the persona file, so with `engine.tier: amplitude` in the file `persona bound` probes through the tier and + its epsilon changes; a certificate computed without the flag does not cover the tier (measured in round 3, section 5). Public text, if any: "an exact simulated qubit register as an optional + solver tier for small programs; measured against the default tier; no quantum hardware". +- **Amplification (round 4, `engine.amplify: true`, off by default).** Instead of projecting, each component's swept state + |ψ⟩ is rotated onto its feasible plans: the rules are the oracle (a phase on the plans that obey them), the diffusion is + about the stored |ψ⟩, k = ⌊π/(4θ) − 1/2⌋ standard iterations with θ = asin √a₀ (a₀ the feasible mass) and one final + iteration whose two phases are chosen so that it lands exactly (`amp::amplify_exact`; mass after asserted ≥ 1 − 1e-12). + Plan and marginals are read from the register's own Born probabilities. The receipt adds `engine.amplify` (iterations and + the final phases per component, the mass before and after) and `feasible_mass` is the mass after (1.000000); the layer list + logs `oracle` and `diffuse` lines. The register holds a second copy of |ψ⟩ while it amplifies (16 · 2^q bytes more). +- Scope (rounds 1-2): the tier answers the turn's solves (the stance and its habit checks). The rest position at the start, the + expectation's members and the trajectory stay on the existing tiers. + +## 4. Bars (registered before any run; PASS/FAIL per bar, failures printed as failures) + +- **B1 exactness.** Marginals from the register agree with an independent reference (a NumPy state-vector implementation + outside the repo) within 1e-9 for q ≤ 12 on 200 random programs and 20 designed ones; gates unitary to 1e-12. +- **B2 replay.** 100 strands with the amplitude tier replay byte for byte on x86_64 and on the no_std build; the 6-decimal + rounding rule holds for every emitted float; a strand recorded with the tier verifies on an engine without it with the + documented `unsupported by this engine` exit (5), never a false divergence. +- **B3 cost.** Host: µs per gate and per `evolve_ising` for q = 8, 12, 16; memory = 16 · 2^q bytes exactly; no_std stack + high-water for q = 8 and 12 ≤ 8 KB (the vector on the heap). Silicon numbers come later. +- **B4 agreement with the p-bit tier (the honest comparison).** On the recorded corpus (653 programs): the level chosen per + trait agrees with the exact p-bit tier in ≥ 90 % of trait decisions under the default schedule, and the cases that differ are + listed with both odds. KEEP as an optional tier if ≥ 90 %; DISCARD as a product tier if < 70 % (kept as a lab experiment); + in between, the verdict is "needs a schedule study", stated as such. +- **B5 interference exists and is bounded.** Three designed programs where two paths cancel: the register's odds differ from the + thermal odds by ≥ 0.2 at the designed node, and the persuadability bound still holds on every trait (0 exceedances on the + 247-program certificate set). +- **B6 the language.** Every public sentence uses "simulated", "exact", "on a CPU"; no claim of quantum hardware or of an + advantage over classical computation; no claim of experience. + +## 5. Measurements so far + +Round 1 (two-valued programs only) and round 2 (multi-level) results are recorded in the change log's Research entries and +the pull request; the verdict on B4 and B5 belongs to round 3. + +Round 2 (multi-level programs, simulated exactly on a CPU; x86_64 host, median of 20): + +| bar | measured | +|---|---| +| B1 exactness | 220 programs mixing two- and three-level variables (q ≤ 12 encoded; 20 designed, among them one where the unused code would be favoured if it were allowed) against an independent mixed-radix reference (one tensor axis of size d per variable, mixer from an eigendecomposition): max \|Δ marginal\| per level 2.7e-13 (bar 1e-9); unused-code mass after every layer 0 (bar 1e-15); mixer unitarity 8.9e-16 for d = 1-8 (bar 1e-12); round 1's 220 two-valued programs through the new layers: 5.3e-15 | +| B2 replay | 100 strands of the tutor example with the tier (three-level traits; 60 encoded qubits in 12 components, the largest 10): recorded twice byte-equal 100/100, verify 100/100 on x86_64 and on the no_std build, re-written byte-equal 100/100, 0 floats past 6 decimals; an engine without the tier: exit 5 on 100/100 | +| B3 cost | one sweep of 16 steps with level marginals: 42 µs (q 8, four three-level traits), 0.82 ms (q 12, six), 16.3 ms (q 16, eight); all two-valued: 97 µs, 2.1 ms, 43 ms; memory 16 · 2^q bytes of amplitudes plus 8 · 2^q of energies; no_std stack high-water 2,016 B at q 8 and 12 | + +The Born mass on plans that obey the rules can be small after the default sweep (as low as 7.3e-05 on the tutor example: +the product over its 12 components; its four-step order component alone allows 24 of its 256 codes); the projection renormalises it. B4 (agreement with the default +tiers, its first pass recorded with the pull request) and B5 have their verdict in round 3. + +Round 3 (the verdict round; simulated exactly on a CPU, x86_64 host): + +| bar | measured | result | +|---|---|---| +| B4 agreement | every program the recorded corpus's 653 outputs solve on the default tiers (replay, live, init, lint, prove, fuzz, the continuation; strand verification left out), solved again by the tier at q_max 16: 338,567 exact solves (4,304 not exact on the default tiers, excluded), 0 declined; the level per trait agrees in **2,095,627 of 2,176,371 decisions = 96.29 %** (the stance path alone, replay and live, 96.52 %; distinct programs 95.94 %; worst traits valence 81.8 %, emoji 86.6 %, arousal 89.7 %). The corpus itself stays byte-identical with the comparison running beside it (653 outputs, 0 differ) | **PASS (≥ 90 %)** | +| B4 where they differ | 80,744 differing decisions, each listed with both odds. By the default tier's odds on its own level: ≥ 0.60 → 99.97 % agree, 0.50-0.60 → 98.3 %, 0.40-0.50 → 93.6 %, < 0.40 → 90.9 %; in 91 % of the differing cases the default level holds less than half the mass (median 0.385): the two disagree where the thermal answer is a plurality, not a majority. By the trait's component's feasible mass: 99.1 % of the differing cases sit in components with mass ≥ 0.1, so they are not an artefact of the projection | stated | +| feasible mass | the receipt's product over components is < 1e-2 on 36.7 % of the solves (min 2e-06), but per component on 0.04 % (min 2.6e-04, median 1): the product multiplies up to 12 components. A rule-penalty term (E' = E + 4 · violations, the projection kept) measured in the lab lifts every component above 7e-03 and drops B4 to 83.44 % (the order rules' step variables move most): not adopted; the projection stays | measured, not changed | +| B5 interference | three designed programs where two paths into a node differ in energy by D (the default sweep turns the difference into a relative phase), designed and predicted by the reference before the engine ran: register vs thermal odds at the node 0.748 vs 1.000, 0.744 vs 0.990, 0.697 vs 0.969 (gaps 0.252, 0.246, 0.272; the same programs at D = 0: 0.001, 0.000, 0.037); engine vs reference within 1.4e-14 | **PASS** | +| B5 bound | the bound harness (7 personas, seeds 0-9, greedy, mixed and self-sourced campaigns) with the tier on: certificate computed from the persona file with the flag, **0 of 4,100 (campaign, trait) cases above epsilon + r** (largest ratio 1.000), self-sourced events refused 10/10; with the certificate computed without the flag, 1,598 of 4,100 above (largest ratio 1.46): the tier moves resting odds further than the thermal tier, so a certificate must be computed with it. Tightness (≥ 0.9 of epsilon reached) fails on one fixture with the tier (0.85) | **PASS** (with that caveat) | + +Verdict: **KEEP as an optional tier** (research; off by default). It reproduces the stance semantics in 96 % of decisions and +differs where the thermal answer is nearly indifferent; it adds interference where designed programs have two paths. Not +verified: silicon cost and replay on a chip. + +### Amplification (round 4) + +Why it changes nothing in the stance, and why that is the point. Write the swept state as |ψ⟩ = sin θ |g⟩ + cos θ |b⟩, with +|g⟩ its normalised part on the plans that obey the rules and |b⟩ the rest. A phase on the feasible plans and the diffusion +about |ψ⟩ both map the plane of |g⟩ and |b⟩ into itself, so every state the amplification visits is α|g⟩ + β|b⟩; one final +iteration with matched phases makes β exactly 0, and the result is |g⟩ up to a global phase. Its Born probability on a +feasible plan x is |ψ_x|² / a₀, which is what the projection computes by renormalising. So the plan and the odds are the +projection's, and the feasible mass is 1: the projection was never an approximation layered over the register, it is the +distribution exact amplification produces by a unitary evolution, simulated exactly on a CPU. Nothing in the argument depends +on the program or the rules; it needs only the exact landing. + +Measured (x86_64 host): on 220 programs (200 random mixed two- and three-level programs and 20 designed ones, a rule each: +seeded random plan subsets at densities 0.03-0.8 or "at most one variable at its top level"; a₀ from 1.7e-06 to 1, 0 to 611 +iterations) the marginals with amplification differ from the projection's by at most 1.1e-12 (bar 1e-9), the feasible mass +after is at least 1 − 3.3e-13 (bar 1 − 1e-12; the largest drift, 1.1e-12 above 1, after 611 iterations), and the most +probable plan is the same on 220 of 220. On every program the recorded corpus solves (339,389 exact solves, each run both +ways) the plans are equal on 339,389 of 339,389 and the marginals differ by at most 2.1e-13, so B4 is round 3's to the +decision: 2,095,627 of 2,176,371 = 96.29 %. A 287-event recorded +day of a 52-qubit persona gives the same stance line on 287 of 287 events (per component 1-2 iterations: the per-component +mass is mostly near 1 and the receipt's small product, median 0.058, multiplies 11 components). Cost: 1.02× the projected +solve on that day (1.19 vs 1.17 ms per event) and 1.06× on a 16-qubit component at a₀ = 0.022 (5 iterations); no_std stack +high-water 2,016 B at q 8 and 12. 100 lives with amplification replay byte for byte on x86_64 and on the no_std build, 0 +floats past 6 decimals, and an engine without the tier exits 5. Turning it on therefore buys a receipt whose register holds feasible mass 1 and logs +how it got there; it does not buy a different stance. + +### Schedules and the indifferent set (round 5) + +Round 4 showed amplification computes what the projection computed, so the only place the register can decide differently is +its evolution. `engine.schedule` names it (needs the tier; absent = `tf-linear-16x4`, byte for byte): `tf-linear-16x4` (the +default sweep, 16 steps over time 4), `tf-linear-64x16` (the same transverse-field sweep, 64 steps over time 16: four times the +layers) or `qaoa-p2` (two QAOA layers, γ 0.500000 β 0.500000 then γ 1.500000 β 0.250000, fixed by a grid search on a held-out +third of the corpus's programs and then frozen). The receipt's `schedule` digest and `schedule_name` name it; an unknown name +is refused. + +The study: on the recorded corpus (339,389 exact solves, 130,088 distinct programs, 2,183,612 trait decisions), the thermal +tier is **indifferent** (its top two levels' odds within 0.05; its own gap a median 0.028) on **234,004 decisions = 10.72 %** +(fuzz 9.9 %, replay and live 15.5-15.7 %, lint 17.5 %). On those decisions, measured on the two thirds of programs the grid +did not see, the register's gap between its top two levels: + +| schedule | median gap | gap > 0.10 | gap > 0.25 | agreement where the thermal tier is not indifferent | +|---|---|---|---|---| +| tf-linear-16x4 (default) | 0.048628 | 9.5 % | 0.1 % | 97.58 % | +| tf-linear-64x16 | **0.151709** | **64.6 %** | **16.0 %** | **98.17 %** | +| qaoa-p2 | 0.094292 | 47.4 % | 7.0 % | 92.99 % | + +Two disjoint samples of programs give medians within 0.000697 of each other, so the long sweep's margin (0.103) is about 150 +times the noise. By the registered rule (the largest median gap subject to agreement ≥ 90 % on the decisions that are not +indifferent) the choice is `tf-linear-64x16`; it is not the default, because the default is what every strand recorded so far +replays. What the longer sweep buys a decision, stated plainly: a slower sweep keeps more of the simulated register's amplitude +on the most probable plans, so it commits where the thermal answer is a near tie (it takes the thermal plurality on 78.5 % of +those decisions, the runner-up on 20.1 %, a third level on 1.4 %), and it keeps the thermal choice where there is one. It does +not create a preference the weights do not hold. Its cost is the layers: 1.5× the default solve per event on a recorded day of a +52-qubit persona (3.1-3.5× on a lone 8-12 qubit component), 0.8× for `qaoa-p2`; no_std stack at most 2,960 B on every schedule. With amplification on, 165 of the 130,088 +programs reach a component mass near 5e-5 under the long sweep and 100+ iterations, where rounding leaves the landing about +1e-12 short of its bar; that component now declines to the default tiers (`amplification missed its 1 - 1e-12 landing`) +instead of stopping the turn (0.12 % of decisions; 0 with the other two schedules). Every number here is a register simulated exactly on a CPU; +the gap is a property of the evolution's length and phases, not a claim about any device. +Replay, measured, and a failure printed as one: 100 lives per schedule record twice byte for byte, verify on the same build and +emit 0 floats past 6 decimals; an engine without the tier exits 5. The no_std build (the chip's math library) re-verifies +the default schedule's lives 100 of 100 on x86_64, but **the long sweep's 0 of 100 and qaoa-p2's 98 of 100**: the platform's +math functions and the no_std library differ in the last bits, and over four times the layers a 6-decimal odd flips. On an arm64 +host even the default fails there (0 of 5 on round 4's own path), so that part is not new. Round 7 (below, "One math path") +makes the tier compute with the same math functions on every build: all three schedules now replay 100 of 100 on the no_std +build on x86_64. + +### The demo (round 6) + +One decision where the thermal tier is a near tie, shown end to end on the recording (std) build: the program, the two odds, +the receipts and the monitor's two faces. Every number is a register simulated exactly on a CPU. + +**The program, from the corpus.** The recorded corpus's tutor fixture (seed 1, script 2, the 58th exact solve of its replay) +solves program `a7fae99b114ea2d2` (first 8 bytes of the sha256 of its compact JSON). Humour, regenerated from the corpus: +thermal `playful` 0.452774 vs `light` 0.442258 (gap 0.010516); the register with the default sweep, gap 0.080846; with the +long sweep, `playful` 0.975625 (gap 0.951467). The runner-up, `07d909f824f224e4` (the same fixture, script 3): thermal +`light` 0.444788 vs `playful` 0.425243 (gap 0.019545), long sweep `playful` 0.972868 (gap 0.945965): here the register takes +the thermal runner-up, as it does on 20.1 % of the indifferent decisions. + +**The persona and the life** (`examples/persona/demo/`). A fictional tutor reduced to its humour component (warmth, humour, +formality, emoji, the valence mood, their couplings and the two habits that bind them) and the two drives that pull humour +in opposite directions: praise up, criticism (a correction) down. `register.yaml` carries `engine: {tier: amplitude, amplify: +true, schedule: tf-linear-64x16}`; `thermal.yaml` is the same persona without the engine block (`amplify` and `schedule` need +the tier). The life, 17 events at seed 1 on a fixed clock, opens with a run of corrections; at event 9 praise and a correction +arrive together. Both strands verify, record twice byte for byte, emit 0 floats past 6 decimals; event 9 is line 10 of both, +and its two documents solve the same program. + +| event 9, humour | none | light | playful | top-two gap | +|---|---|---|---|---| +| thermal (default tiers) | 0.059462 | 0.466563 | 0.473975 | **0.007412** | +| register, default sweep (comparison) | 0.013180 | 0.566151 | 0.420670 | 0.145481 | +| register, long sweep (`register.yaml`) | 0.007171 | 0.249010 | 0.743818 | **0.494808** | + +The register document's receipts at event 9: `tier` amplitude, `qubits` 20 (`components` [10, 10]), `mixer` complete-graph, +`schedule_name` tf-linear-64x16, `amplify` iterations [10, 1] with the mass before 0.006196 and after 1, `feasible_mass` 1, the +`state` digest `sha256:6dbfd5a4…1bbe11`, the program digest `sha256:06517240…ab9a66` (the thermal document's too). Events 2 and +11 are near ties of the same kind (thermal gaps 0.033093 and 0.048852, register gaps 0.656343 and 0.725300). The register is not +always the more decisive face: at event 14 the thermal gap is 0.072051 and the register's 0.009221. At event 9 the default sweep +leans to `light` (gap 0.145481); the long sweep commits to `playful`, the thermal plurality. + +**The two faces.** The Mood Monitor (`probbit monitor`, section 5.8 of docs/persona.md) on each strand at event 9, replayed +from the strand alone ("replay verified"; the document it serves for event 9 is the recorded one, byte for byte), humour +selected. Thermal: `playful` 47.4 %, `light` 46.7 %, the panel's words "playful, slightly". Register: `playful` 74.4 %, +`light` 24.9 %, "playful, clearly". + +![The Mood Monitor on the thermal strand at event 9, humour selected](0011-demo-thermal.png) + +![The Mood Monitor on the register strand at event 9, humour selected](0011-demo-register.png) + +**The sentence.** On one recorded decision of a fictional tutor, the default solver's odds for its two leading humour levels +sit 0.007412 apart; the same program, solved by a qubit register simulated exactly on a CPU, separates them by 0.494808. +Since round 7 both strands re-record byte for byte and re-verify on the no_std build as well ("One math path", below). + +**Cost** (x86_64, best of 5, the life repeated 20 times, 340 events): thermal 2,184 µs per event, the register with the long +sweep 1,822 µs (0.83× the thermal copy), the register with the default sweep 913 µs (the long sweep 2.00× the default sweep). +On this five-variable persona the thermal copy's exact enumeration costs more than the register. + +### One math path (round 7) + +What an optional tier needs: the same odds on every build, no declines, the numbers re-measured. The register, simulated +exactly on a CPU, now evaluates every function past + − × ÷ (cos, sin, sqrt, asin, acos, atan2, floor) through the `libm` +crate on the std and the no_std build alike: pure Rust, the same code and evaluation order on every target, no platform math +library, no fused multiply-add. Measured on x86_64 (100 fresh tutor lives per schedule recorded on the std build, each +re-verified and re-written by a no_std build of the persona crate on the host): + +| schedule | recorded twice byte-equal | verify (std) | verify (no_std) | no_std re-write byte-equal | strands equal to the round-6 engine's | +|---|---|---|---|---|---| +| `tf-linear-16x4` (default) | 100/100 | 100/100 | **100/100** (round 5: 100/100) | 100/100 | 100/100 | +| `tf-linear-64x16` | 100/100 | 100/100 | **100/100** (round 5: 0/100) | 100/100 | 0/100 | +| `qaoa-p2` | 100/100 | 100/100 | **100/100** (round 5: 98/100) | 100/100 | 98/100 | + +The tutor's default-schedule strands are byte-identical to the round-6 engine's and both demo strands re-record byte for byte, +but the change is in the last bits and a larger persona's default strands can move: on a recorded day of a 52-qubit persona +they did (qaoa-p2's did not); the long sweep's tutor strands all moved. An unreleased tier allows it; no shipped fixture moved. +The study of round 5 re-run on this tree: 0 programs decline under any schedule (round 5: 165 under the long sweep), the long +sweep's median gap on the indifferent decisions 0.151709 and agreement on the others 0.981671 (round 5: 0.151709, 0.981717, +now over the formerly declined decisions too), the default's and qaoa-p2's rows unchanged to 6 decimals; cost per event on that +day 1.50x the default for the long sweep (branch vs the round-6 engine 0.95-1.00x); no_std stack at most 2,944 B per schedule. Both demo strands re-record byte for byte. The arm64 leg is not measured (no arm64 runner for the replay). + +The missed landing: the diffusion about a swept state of squared norm 1 + ε is not unitary (|Dv|² = |v|² + 4ε|⟨ψ|v⟩|²), so +the error grows with every iteration, linearly in their number; at a tiny feasible mass (hundreds of iterations) the long +sweep's ε left 165 corpus programs about 1e-12 short of the landing, and the same ε of the other sign is round 4's drift above +1. `amplify_exact` now renormalises the swept state once before the standard iterations (squared norm by a compensated sum; +only when there are iterations, so the one matched step of a mass above 1/4 is unchanged), and a mass that is not a number +declines like a miss. A rotation in the plane would have hidden the cause; renormalising keeps every iteration the same +unitary step. The receipt's mass before stays the swept state's; `feasible_mass` stays 1. + +## 6. What this RFC does not claim + +That a CPU can do what quantum hardware does at scale (it cannot: section 1), that the register has experiences (it is +arithmetic), or that it beats the p-bit tier (B4 decides). What it can honestly give: superposition with real phases and +interference inside the agent's decision, exact and replayable, simulated on a CPU at a size a laptop and a small chip can +afford. diff --git a/docs/rfc/0011-demo-register.png b/docs/rfc/0011-demo-register.png new file mode 100644 index 0000000000000000000000000000000000000000..5356fc247f2d3cb998f7ee8612d5d71af040358b GIT binary patch literal 176133 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 >0g==*smI^p&D!xy_&qYYX--Pf>Lm#Vjd9gPR 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
r|jh%KdFmu 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 G^ZtLWiADSX literal 0 HcmV?d00001 diff --git a/docs/rfc/0011-demo-thermal.png b/docs/rfc/0011-demo-thermal.png new file mode 100644 index 0000000000000000000000000000000000000000..4fe8a481493a79ab8c0ce9b972776781004ea7a0 GIT binary patch literal 175737 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 literal 0 HcmV?d00001 diff --git a/examples/persona/demo/README.md b/examples/persona/demo/README.md new file mode 100644 index 0000000..8539d19 --- /dev/null +++ b/examples/persona/demo/README.md @@ -0,0 +1,51 @@ +# The amplitude register demo (research, RFC 0011) + +One decision, shown twice. A fictional tutor reduced to its humour component lives the same 17 events twice: once on the +default tiers (`thermal.yaml`, the thermal face) and once on the amplitude register (`register.yaml`), an exact simulated +qubit register on a CPU, run with the long sweep (`tf-linear-64x16`) and exact amplification onto the plans its habits allow. +No quantum hardware is involved and no speedup is claimed: every number below is arithmetic, exact and replayable, simulated +on a CPU. + +Two opposed drives pull humour: praise raises it (with valence and emoji), criticism, a correction, lowers it. The life +(`life.jsonl`, seed 1, fixed clock) opens with a run of corrections; at **event 9** praise and a correction arrive together. + +| event 9 (strand line 10), humour | none | light | playful | top two gap | +|---|---|---|---|---| +| thermal (default tiers) | 0.059462 | 0.466563 | 0.473975 | **0.007412** | +| register, default sweep (`tf-linear-16x4`, for comparison) | 0.013180 | 0.566151 | 0.420670 | 0.145481 | +| register, long sweep (`tf-linear-64x16`, this copy) | 0.007171 | 0.249010 | 0.743818 | **0.494808** | + +On the same decision the thermal odds of the top two humour levels sit 0.007412 apart (a near tie: the drives cancel), and +the simulated register separates them by 0.494808: it commits to `playful`, the thermal plurality (the default sweep, four +times shorter, leans the other way, to `light`, by 0.145481). It does not invent a preference the weights do not hold; a +slower sweep keeps more of the register's amplitude on the most probable plans (docs/rfc/0011-amplitude-register.md, +"Schedules and the indifferent set" and "The demo"). Events 2 and 11 are near ties of the same kind (praise and a correction +together: thermal gaps 0.033093 and 0.048852, register gaps 0.656343 and 0.725300); the register is not always the more +decisive face (event 14: thermal gap 0.072051, register gap 0.009221). + +## Files + +| file | what | +|---|---| +| `register.yaml` | the persona with `engine: {tier: amplitude, amplify: true, schedule: tf-linear-64x16}` | +| `thermal.yaml` | the same persona without the engine block (`amplify` and `schedule` need the tier, so all three go) | +| `life.jsonl` | the 17 events | +| `register.strand`, `thermal.strand` | the two recorded lives (`probbit live verify` checks each); event 9 is line 10 of both | + +Record and check them again: + +```sh +probbit live examples/persona/demo/register.yaml --seed 1 --clock fixed --events examples/persona/demo/life.jsonl --strand register.strand +probbit live examples/persona/demo/thermal.yaml --seed 1 --clock fixed --events examples/persona/demo/life.jsonl --strand thermal.strand +probbit live verify register.strand +probbit monitor register.strand --open # the Mood Monitor (docs/persona.md, section 5.8); thermal.strand beside it +``` + +The register strand's documents carry the receipts: `engine.tier` `amplitude`, `engine.schedule_name` `tf-linear-64x16`, +`engine.amplify` (iterations, phases, the mass before and after), `feasible_mass` 1.000000 and the `state` digest of the final +amplitudes at 6 decimals. + +**Replay on every build.** Since round 7 the register computes its math functions with one library on the std and the no_std +build, so the long sweep replays on both: 100 fresh lives per schedule recorded on the std build re-verify on the no_std build +100 of 100 (x86_64; arm64 not measured), and both strands here re-record byte for byte and verify on the no_std build. An +engine without the tier (0.13.0) refuses `register.strand` (exit 5) and verifies `thermal.strand`. diff --git a/examples/persona/demo/life.jsonl b/examples/persona/demo/life.jsonl new file mode 100644 index 0000000..6906ae8 --- /dev/null +++ b/examples/persona/demo/life.jsonl @@ -0,0 +1,17 @@ +{"elapsed_hours":2} +{"criticism":true,"elapsed_hours":4,"praise":true} +{"criticism":true,"elapsed_hours":1} +{"criticism":true,"elapsed_hours":1} +{"criticism":true,"elapsed_hours":4} +{"criticism":true,"elapsed_hours":1} +{"criticism":true,"elapsed_hours":1} +{"criticism":true,"elapsed_hours":1} +{"criticism":true,"elapsed_hours":0,"praise":true} +{"elapsed_hours":1,"praise":true} +{"criticism":true,"elapsed_hours":2,"praise":true} +{"elapsed_hours":0,"praise":true} +{"elapsed_hours":0} +{"criticism":true,"elapsed_hours":1} +{"elapsed_hours":1} +{"criticism":true,"elapsed_hours":4} +{"criticism":true,"elapsed_hours":4} diff --git a/examples/persona/demo/register.strand b/examples/persona/demo/register.strand new file mode 100644 index 0000000..0e7df81 --- /dev/null +++ b/examples/persona/demo/register.strand @@ -0,0 +1,18 @@ +{"probbit_strand":1,"engine":"probbit 0.14.0-dev","persona":{"name":"demo-tutor","version":"0.1.0","digest":"sha256:0f90e48d4690395fb90aa16ca0b71d1cbb34c9aa411d880fe996ca487762d6d3"},"seed":1,"state":{"digest":"sha256:c84048d4325525a85d790f4a919c3226cf5a2c2ff97c274656445bb4adfa8ea7","genes":{"react":{"praise":0.7987},"shift":{"emoji":0.4947,"formality":0.3187,"humour":0.2479,"valence":0.0793,"warmth":0.6261}},"history":{},"mood":{"valence":[0,0,0]},"persona":{"digest":"sha256:0f90e48d4690395fb90aa16ca0b71d1cbb34c9aa411d880fe996ca487762d6d3","name":"demo-tutor","version":"0.1.0"},"prev":{"emoji":"sparse","formality":"casual","humour":"light","valence":"even","warmth":"warm"},"probbit_persona_state":1,"rest":{"emoji":"sparse","formality":"casual","humour":"playful","valence":"up","warmth":"warm"},"seed":1,"turn":0},"document":{"probbit_persona":1,"identity":{"name":"demo-tutor","version":"0.1.0","seed":1,"summary":"a tutor reduced to its humour component, for the amplitude register demo"},"traits":[{"id":"warmth","levels":["cool","neutral","warm"],"prior":[0.05,0.3,0.65],"spread":0.5,"say":["plain","","warm and encouraging"]},{"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":"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}],"couplings":[{"vars":["valence","humour"],"align":1},{"vars":["valence","emoji"],"align":0.6},{"vars":["warmth","formality"],"align":-0.6},{"vars":["humour","formality"],"align":-0.6},{"vars":["humour","emoji"],"align":0.4}],"inputs":[{"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,"humour":-0.4}}],"habits":[{"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"}],"engine":{"tier":"amplitude","amplify":true,"schedule":"tf-linear-64x16"},"line":{"max_tokens":40,"order":["humour","warmth","emoji","formality"]}}} +{"inputs":{"elapsed_hours":2},"n":1,"prev":"sha256:f7f918deac79a089d818d078c2107af9a8551cf0c1e13f501880b1f0310f3055","stance":"sha256:0ea75eaaaad2925f1d840f8f244f15184767e5abb0893693e1c421b9843b293f","state":"sha256:96bd28501be3af6b52e55cb44312577d77bd48e3ff1580fec4dec25e77bcde91"} +{"inputs":{"criticism":true,"elapsed_hours":4,"praise":true},"n":2,"prev":"sha256:0d3c0f851600e4502ce2cd66f5bc3912cb2828a268c09eba4c8cf433e2a23b64","stance":"sha256:15d64c824bef92c0baea4d6217217b6b5ad66cdc52627ec094dcb2543668cd73","state":"sha256:9bae644f633fecbfb9860f19c648ccf991646b2024d60a4faee8a7c0909e9a0f"} +{"inputs":{"criticism":true,"elapsed_hours":1},"n":3,"prev":"sha256:69f5fe05b842eb256f07ef09992e352afd0d88722714dfeaafa3b793fb20d26d","stance":"sha256:558208d4b53faa28280b7c8e2a1ad5180d67caeb4dcebf9f9f223fa71a25995d","state":"sha256:5aebd4d81ef411cea573cf951c23455fa1308423461d10e45b9f3501d783eb06"} +{"inputs":{"criticism":true,"elapsed_hours":1},"n":4,"prev":"sha256:04621bc267dde076be8068938e9575c66a17ec6536f037f8dad3199a3e034492","stance":"sha256:2ad864a848ec499e8657c289c67b1d8e8afee3e5271eb2d24aeaecbf325f0987","state":"sha256:558b0ccd0cbf5a67d31ad9a991dfae9165536b689c4facba7210edb0ed7fc71d"} +{"inputs":{"criticism":true,"elapsed_hours":4},"n":5,"prev":"sha256:c4c9250a865415d3eb56da2fdb8762dd3b4fc05da37f0b70fd77151fa3352ee1","stance":"sha256:6fc813956b1e08708ee757b12409fbca5eed52fc21a3aa01c005cf5ba6ebba68","state":"sha256:5abdbb412a343850083273df229de2dfb644ee8ddf349e78d4f0b18148652403"} +{"inputs":{"criticism":true,"elapsed_hours":1},"n":6,"prev":"sha256:8481f42add2e7de64387488ad014b129612b554f7fdddaf67bb04adeb3c59bcf","stance":"sha256:6f0cc3ce009e3109f4b20452957596790000b4d44650b49d610eab7bc5dd3350","state":"sha256:eb93d29cfb413b01a34dccfa2a4a61f29b6be597de7da6a04ed2913a0c8ed5c8"} +{"inputs":{"criticism":true,"elapsed_hours":1},"n":7,"prev":"sha256:81394f399b0e08559d9c0714de9c14094631cf238c0c35faee58365de220a647","stance":"sha256:476759a6ca2e3202f9d25b85fa4da5e5c187f5dc8c6eed104180c4555906f267","state":"sha256:ca12842fe89b3d9ee78cb3244c8540dd600f9d3878e5fa948014e8b1d2502c02"} +{"inputs":{"criticism":true,"elapsed_hours":1},"n":8,"prev":"sha256:15d826747f1967a0bd7f7c81a51163b99d31601d87c0d7b3befe6ac356368915","stance":"sha256:2073a8a79dc30b3d012216e987d165b978cbfa35120c3ad537a8bd54490b95a9","state":"sha256:ef66448369e444c52d61d46f091397b0e5cb38bdd964aeda5431dac6742e6da2"} +{"inputs":{"criticism":true,"elapsed_hours":0,"praise":true},"n":9,"prev":"sha256:a9b010dc46e7b37094605c515f7942e38d6e12367ec44bc5e2118da5ed974da7","stance":"sha256:390469686dc6805079079259e69c8d6363cbc6f6a07790687bbc741ce631d641","state":"sha256:852c7969fde1a0ce103be8a354e6e69a2c399c3711be1e5c1e125fac22ab0b87"} +{"inputs":{"elapsed_hours":1,"praise":true},"n":10,"prev":"sha256:75e5cd3fe5f0e41c40adca3a193c766c214d752fd37e7f4119a4d1bc47c33127","stance":"sha256:7061e8197c0bb2cdeae248bafd3479ce2af83b370550f4a6aab2e5dfd6ff2a61","state":"sha256:e57e940272213655514b18d06079e24ab3793cc3a39a8b152c0a8b5756bbeda9"} +{"inputs":{"criticism":true,"elapsed_hours":2,"praise":true},"n":11,"prev":"sha256:3de8cb027dd83fe5b20af21e1b81f7ccfdd57a076528a60bc7b29b055ea95e93","stance":"sha256:9f2eb1cd70dacd6bc0fc1703823ed8705812ca8436806cf5e3163b7023b3f711","state":"sha256:9e3b37108bbe2459b25ca8b57bdbe224370d39780d271209f9be93819dab0fbd"} +{"inputs":{"elapsed_hours":0,"praise":true},"n":12,"prev":"sha256:c6abea7b6d9889993c1fb7d5ed0f08ee8d5c0dafb0dccebcafc52b0fcfda6b65","stance":"sha256:14a8c85a0c15bac274c20df42eea43c1624f617effcee960e4b0406cfb14959a","state":"sha256:5fc5e75832bf7d4f6cc41fd8e17b309c944ae5bf6b83adc1da3b405fb81c7a5e"} +{"inputs":{"elapsed_hours":0},"n":13,"prev":"sha256:81a906e06d49b7e2c4eda69967beeb6d5cd72ba05f65aa6f63f748ec1f76a0a8","stance":"sha256:f8435ba6ab5dc84b145acdafbb4991bec830f457acb113ab486198608112144f","state":"sha256:862d484cbab209ddc35e92f663d4d4df4e64c4fdbf46ede4f446197c5ece2466"} +{"inputs":{"criticism":true,"elapsed_hours":1},"n":14,"prev":"sha256:35edb356815aa9c4c0d93ff43fa18e110b8010acdcadae5cccb552d30d7eaa0b","stance":"sha256:27a6f948c0adb0921ed8889cb4462f7d496cb4718951776c4f0170354f9768dc","state":"sha256:d77a30a5c2a0a72569e41cd19fd9f9f9bb58f88c93a90937c397d496ddbe54de"} +{"inputs":{"elapsed_hours":1},"n":15,"prev":"sha256:abb69d92e54ee697203e354c08f423d8b2287d7e1e45d88f0a34173f82ea0b21","stance":"sha256:ab8a1ae0a39f34c8adc6fc22d8dc518d4d703fbea724d88c9447a00ede3fb829","state":"sha256:1cb4c8f07d6084365a1d5014605a004123d40afb218ecef33be5a0f4d696490f"} +{"inputs":{"criticism":true,"elapsed_hours":4},"n":16,"prev":"sha256:b817992a9ee62b66b8fe03c7e17ad829a19ae84483a807ff7411ceb7e4a065e2","stance":"sha256:bddc0d05e986a851acd237f66b6b05c7ab0faf1d5a116b76367d901837607e7a","state":"sha256:00948463ada05fad5d4874e98383cb471a1698fc08e526af423bf75703601925"} +{"inputs":{"criticism":true,"elapsed_hours":4},"n":17,"prev":"sha256:d85613c8c1c24c6b4d6670fff73593305807d9d6e6020a44dd98abe388d6ba15","stance":"sha256:4be10566f4e0e16ca456d04c82e4706005f88525959c58cd0af5aeae98360e51","state":"sha256:e63a0e17d618492147c7a573b28db4f70f893b1350f9e403b7dffe037cbf2201"} diff --git a/examples/persona/demo/register.yaml b/examples/persona/demo/register.yaml new file mode 100644 index 0000000..bc4c3da --- /dev/null +++ b/examples/persona/demo/register.yaml @@ -0,0 +1,78 @@ +# A fictional persona for the RFC 0011 demo (research, off by default elsewhere): a tutor reduced to its humour component. +# probbit persona format 1 (docs/persona.md). Two opposed drives pull humour: praise raises it, criticism (a correction) +# lowers it. This copy solves each turn on the amplitude register: an exact simulated qubit register on a CPU, run with the +# long sweep (tf-linear-64x16) and exact amplification onto the plans the habits allow. thermal.yaml is the same persona on +# the default tiers. No quantum hardware, no speedup claim. See README.md and docs/rfc/0011-amplitude-register.md, "The demo". +probbit_persona: 1 +identity: + name: demo-tutor + version: 0.1.0 + seed: 1 + summary: a tutor reduced to its humour component, for the amplitude register demo + +traits: + - id: warmth + levels: [cool, neutral, warm] + prior: [0.05, 0.3, 0.65] + spread: 0.5 + say: [plain, "", warm and encouraging] + - 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: 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 + +couplings: + - {vars: [valence, humour], align: 1.0} + - {vars: [valence, emoji], align: 0.6} + - {vars: [warmth, formality], align: -0.6} + - {vars: [humour, formality], align: -0.6} + - {vars: [humour, emoji], align: 0.4} + +inputs: + - 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, humour: -0.4} + +habits: + - 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 + +engine: + tier: amplitude # research (RFC 0011): an exact simulated qubit register on a CPU + amplify: true # research (round 4): exact amplification onto the plans the habits allow (a receipt change) + schedule: tf-linear-64x16 # research (round 5): the long sweep; replays on the recording (std) build only for now + +line: + max_tokens: 40 + order: [humour, warmth, emoji, formality] diff --git a/examples/persona/demo/thermal.strand b/examples/persona/demo/thermal.strand new file mode 100644 index 0000000..13d64e9 --- /dev/null +++ b/examples/persona/demo/thermal.strand @@ -0,0 +1,18 @@ +{"probbit_strand":1,"engine":"probbit 0.14.0-dev","persona":{"name":"demo-tutor","version":"0.1.0","digest":"sha256:f63a2a990dce5c0290a3fb9d3148f945b9ba985b9a50897b33ac7e9589f44f47"},"seed":1,"state":{"digest":"sha256:8442eba81d5ab11311bd7367c272ef54da3963c18e0b6f7a044e788588202f2c","genes":{"react":{"praise":0.7987},"shift":{"emoji":0.4947,"formality":0.3187,"humour":0.2479,"valence":0.0793,"warmth":0.6261}},"history":{},"mood":{"valence":[0,0,0]},"persona":{"digest":"sha256:f63a2a990dce5c0290a3fb9d3148f945b9ba985b9a50897b33ac7e9589f44f47","name":"demo-tutor","version":"0.1.0"},"prev":{"emoji":"sparse","formality":"casual","humour":"light","valence":"even","warmth":"warm"},"probbit_persona_state":1,"rest":{"emoji":"sparse","formality":"casual","humour":"playful","valence":"up","warmth":"warm"},"seed":1,"turn":0},"document":{"probbit_persona":1,"identity":{"name":"demo-tutor","version":"0.1.0","seed":1,"summary":"a tutor reduced to its humour component, for the amplitude register demo"},"traits":[{"id":"warmth","levels":["cool","neutral","warm"],"prior":[0.05,0.3,0.65],"spread":0.5,"say":["plain","","warm and encouraging"]},{"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":"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}],"couplings":[{"vars":["valence","humour"],"align":1},{"vars":["valence","emoji"],"align":0.6},{"vars":["warmth","formality"],"align":-0.6},{"vars":["humour","formality"],"align":-0.6},{"vars":["humour","emoji"],"align":0.4}],"inputs":[{"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,"humour":-0.4}}],"habits":[{"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"}],"line":{"max_tokens":40,"order":["humour","warmth","emoji","formality"]}}} +{"inputs":{"elapsed_hours":2},"n":1,"prev":"sha256:67aef832803e96fc96d37f73fe25d8c26effc0a8c43ce9d4d2a613607017a2bc","stance":"sha256:838aefc4a6cae9d87efd37366f83697fb07c0f09d8b319cb279df92b48af7699","state":"sha256:961f1e5a1e85c4f904a997dbddc194406c40153d6bfc35c72fc2a47eec4e2e28"} +{"inputs":{"criticism":true,"elapsed_hours":4,"praise":true},"n":2,"prev":"sha256:684c4505b81eaa3d095c0356fdcf2032a848889d3b956ca3bfdaef584b654d84","stance":"sha256:a11cf3d94c1397aa720ec97b9ec0061f5c1ae2a4abd3b9eb25f415e8e1e3c3ab","state":"sha256:2a54fbe4288035defa4bf85e00017631062f1813d047bb5626051a144921ee05"} +{"inputs":{"criticism":true,"elapsed_hours":1},"n":3,"prev":"sha256:a5e8b2ccf957d240cd780eda5de1b3fd3ea2555fa0b4e3d9645aa42647e373ca","stance":"sha256:3df181cb189f1e8d55f8c68b95db7ec1dd29084e9ac7d545100d527dad863462","state":"sha256:4ccb76820044ec00bbde3741e4a840639369a14e06fc4b848cd81f2ab377e331"} +{"inputs":{"criticism":true,"elapsed_hours":1},"n":4,"prev":"sha256:90297f13f0eaeb0e349aabf0cf9ce67cd427928f0b08bf5a35c627af1d42903d","stance":"sha256:06141011e3c0aaba94fb6b8d7810a14a87b139d4a00486a2696742802993c943","state":"sha256:52510a0e4fea2f04196846b2d02953473ffd0948b0e237936ba2b33430b86605"} +{"inputs":{"criticism":true,"elapsed_hours":4},"n":5,"prev":"sha256:89268a6942a74765a9272176f884894490e9b7f3198c93ba154900a3da840c79","stance":"sha256:e966521e32cadcbf9b6602499935f5bae877eb9904b1a4b37931e99d3234c471","state":"sha256:947106cafbed21b56cea4b100e2d28910063801192112a325b11d9c76e38fe6a"} +{"inputs":{"criticism":true,"elapsed_hours":1},"n":6,"prev":"sha256:62fefaeeb8143826f699f4e30d6013963273ee6eee71cf618120fe0726beb2b0","stance":"sha256:e4b1dbe7aa9da19d39b93c66bce25b951fba68f74d017971dbc1a1af35e7cbfe","state":"sha256:c86b66ef6d7b2ad86c471b729c4cc8ef20b236d131eabf12125c861ec67f6a40"} +{"inputs":{"criticism":true,"elapsed_hours":1},"n":7,"prev":"sha256:5507d0ecd8ae5252fde735d41766b5210158cb768c810f740b611aa69eadb908","stance":"sha256:39ed1c3015adf01cc7acd31447d0e68dfaadcd921f5c7f4c300f8dac309b50e0","state":"sha256:15a93dec6649e8761ff6988bdf375eaddc3ef4ece242265d408144039d77c8f6"} +{"inputs":{"criticism":true,"elapsed_hours":1},"n":8,"prev":"sha256:676480fca7616f81bb0cfbbc626c0ff5f5e22afe4eb0e0080effbbcf430a0d9f","stance":"sha256:27e0f52ebe689dc3c99fc394b1cb95dda66326412c1f434878ed0ca30d311e40","state":"sha256:70d7f743627196ec4045afe54bb3b0f0f1de2e75697a543075e4ff48517490c3"} +{"inputs":{"criticism":true,"elapsed_hours":0,"praise":true},"n":9,"prev":"sha256:bc04904c165cfe76b8c59f226c01e8357b32bbd34783d4d450b19f0640b828fc","stance":"sha256:944c0a26385c27a5ccf35b0e21459b1cc5f5f60f3320a5fbaad22aa50552930a","state":"sha256:7e35e79810e249b30af48a8c6457028a0455a8ec25d3fc84a45e3261f7d08d43"} +{"inputs":{"elapsed_hours":1,"praise":true},"n":10,"prev":"sha256:1f6de80d7bdb4fdd26baec686f120d9916c346951e8c796eb99440466e603768","stance":"sha256:00e1ac558fc6fbf6c04fab4bf406a4dd4da3f1ca9070471a8044a23d4a6dfc1b","state":"sha256:df0cf433b0c57044e3d9edfa1e9f521d1f7d6fa49dd71e7d3df37899420bbb76"} +{"inputs":{"criticism":true,"elapsed_hours":2,"praise":true},"n":11,"prev":"sha256:6335744d8462b3905ced3cc0981affc24e56ce1998764b018aa35893067b6516","stance":"sha256:ef62f91b701fc242b26b9992780d97b4c28084d01754b90eef3bef7ae0acef39","state":"sha256:3579d66c48d8cf50df61c9ab1bd4fffc0002e7225e9db3e3ae08f35ce73c91cd"} +{"inputs":{"elapsed_hours":0,"praise":true},"n":12,"prev":"sha256:25aa44fb4fe28fe86b62641afeb4157882a5554a3db23e7e1fe231481d06f79e","stance":"sha256:01cb4eca2af8d84540275cbea13364903342fe663755a950d4f341a186243786","state":"sha256:ed8b0c8dd993f2541cdfe986ee16b7f304af5dd28f818a808f0ebf54937a9023"} +{"inputs":{"elapsed_hours":0},"n":13,"prev":"sha256:cb5a0222b37e05c37bfe2b1414d4916dc0d28b2c6bbcc9c0a634a83e7995260d","stance":"sha256:106891ba89bdb843de3ac3990f3fe2d9cb4fdbf430270a13b6b44589aeb89abe","state":"sha256:e304fc76b0218fb96bfdd691e34a224acf47ee100527b325d160c266b8c651af"} +{"inputs":{"criticism":true,"elapsed_hours":1},"n":14,"prev":"sha256:4fa4c61ee945583a8f7e469ada36992374a36c665e35ce4db5ca0c6abd08ecf5","stance":"sha256:738deedd442f853a783f9e9d8622ef230c38e665f878114cbdaf1c0c47d3fc4a","state":"sha256:c21e698198fa9034915279a26974ab54faabfa11c6b5dde1c8e94a1a8e81e699"} +{"inputs":{"elapsed_hours":1},"n":15,"prev":"sha256:1689bbc90fd8f3bc326167de45536deb549351c7e6a22e8152b7036103c72db7","stance":"sha256:74ba46799b86f3174f9aa957fbb3f9016bb31175805c4ad13429f7122b28e5c8","state":"sha256:dba20f77fc79cc516040614baf77d9be139d316e0a71c22590963385a39d5fbe"} +{"inputs":{"criticism":true,"elapsed_hours":4},"n":16,"prev":"sha256:9238433fe1e0a0eff7592a1e5d15b45c6ab0e7176abdfc3c2462991c5ba2ffd4","stance":"sha256:b961d8cc9db2e80c4f264be1789c5a5fc39035af6bf68e6ab57bea2bb84506d5","state":"sha256:be90cd8467f85d215724231e1df674f74c12a57c47995fcb66c18d900311cdef"} +{"inputs":{"criticism":true,"elapsed_hours":4},"n":17,"prev":"sha256:6b75ed2e5ea4ac0c2c6d22eba956c12aa3c6d1d1536296676c33ba1d6235669b","stance":"sha256:89fe74fb1b8a54c0bdd346313fd0600f43036611a23fa815b8da96afae00a8a9","state":"sha256:fabd63a75c5564c24e68b015c5ffb8428de219888226d72f5c5f6534eb28e87f"} diff --git a/examples/persona/demo/thermal.yaml b/examples/persona/demo/thermal.yaml new file mode 100644 index 0000000..503f652 --- /dev/null +++ b/examples/persona/demo/thermal.yaml @@ -0,0 +1,71 @@ +# A fictional persona for the RFC 0011 demo: register.yaml on the default tiers (the thermal face). probbit persona format 1. +# Identical to register.yaml except that the engine block is absent: engine.amplify and engine.schedule need engine.tier, so +# removing the tier removes all three. Every engine since 0.13.0 runs this copy. See README.md. +probbit_persona: 1 +identity: + name: demo-tutor + version: 0.1.0 + seed: 1 + summary: a tutor reduced to its humour component, for the amplitude register demo + +traits: + - id: warmth + levels: [cool, neutral, warm] + prior: [0.05, 0.3, 0.65] + spread: 0.5 + say: [plain, "", warm and encouraging] + - 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: 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 + +couplings: + - {vars: [valence, humour], align: 1.0} + - {vars: [valence, emoji], align: 0.6} + - {vars: [warmth, formality], align: -0.6} + - {vars: [humour, formality], align: -0.6} + - {vars: [humour, emoji], align: 0.4} + +inputs: + - 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, humour: -0.4} + +habits: + - 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 + +line: + max_tokens: 40 + order: [humour, warmth, emoji, formality] diff --git a/probbit-decide/Cargo.toml b/probbit-decide/Cargo.toml index 9da17e3..3638747 100644 --- a/probbit-decide/Cargo.toml +++ b/probbit-decide/Cargo.toml @@ -17,6 +17,8 @@ std = ["probbit-core/std", "probbit-ir/std"] nostd = ["dep:probbit-nostd", "probbit-core/nostd", "probbit-ir/nostd"] [dependencies] +# the amplitude register's one math path (cos, sin, sqrt, asin, acos, atan2) on the std and the no_std build alike (pure Rust) +libm = "0.2" probbit-nostd = { path = "../probbit-nostd", version = "0.14.0-dev", optional = true } probbit-core = { path = "../probbit-core", default-features = false, version = "0.14.0-dev" } probbit-ir = { path = "../probbit-ir", default-features = false, version = "0.14.0-dev" } diff --git a/probbit-decide/src/amp.rs b/probbit-decide/src/amp.rs new file mode 100644 index 0000000..6b29ac0 --- /dev/null +++ b/probbit-decide/src/amp.rs @@ -0,0 +1,506 @@ +//! RFC 0011 (research, rounds 1-2): an amplitude register. A register of q qubits simulated exactly on a CPU as a state vector of +//! 2^q complex amplitudes (f64 pairs, on the heap: 16 * 2^q bytes): unitary gates with phase, interference between basis states, +//! exact marginals (no sampling), measurement by the Born rule with the engine's deterministic RNG, and a digest of the state at +//! 6 decimals (the rule for every emitted float). A simulation of a small quantum system and nothing else: no quantum hardware, +//! no speedup claim, no scaling past what 2^q amplitudes allow. +//! +//! Basis index x = sum of bit_i << i: qubit i is bit i. Round 2: a variable with d allowed values takes ceil(log2 d) qubits +//! (`Encoding`; d = 3: codes 00, 01, 10, code 11 unused), the start is the uniform superposition over the valid codes, the +//! mixer is block-diagonal per variable (`MIXER`, the identity on unused codes) and the cost is one general diagonal phase +//! layer exp(-i g E) with E = -log w per basis state, so pair tables between multi-level variables (3- and 4-body terms in +//! the qubits) need no decomposition. The unused codes keep amplitude 0 (checked after every layer). +#[allow(unused_imports)] +use std::prelude::rust_2021::*; +use probbit_core::Philox4x32; + +/// Round 7: the register's one math path. Every function past + - * / that the register evaluates (cos, sin, sqrt, asin, acos, +/// atan2, floor) is the `libm` crate's on every build, std and no_std alike: pure Rust, the same code and the same evaluation +/// order on every target, no platform libm and no fused multiply-add, so a strand recorded on one build replays bit for bit on +/// the other whatever the schedule's length (round 5: the platform's cos/sin against libm's flipped a 6-decimal odd over the +/// long sweep's layers). +mod m { + #[inline] pub fn cos(x: f64) -> f64 { libm::cos(x) } + #[inline] pub fn sin(x: f64) -> f64 { libm::sin(x) } + #[inline] pub fn sqrt(x: f64) -> f64 { libm::sqrt(x) } + #[inline] pub fn asin(x: f64) -> f64 { libm::asin(x) } + #[inline] pub fn acos(x: f64) -> f64 { libm::acos(x) } + #[inline] pub fn atan2(y: f64, x: f64) -> f64 { libm::atan2(y, x) } + #[inline] pub fn floor(x: f64) -> f64 { libm::floor(x) } +} + +/// The largest register on the host (16 qubits = 1 MiB of amplitudes) +pub const Q_MAX_HOST: usize = 16; +/// Bytes per amplitude: two f64 +pub const AMP_BYTES: usize = 16; + +/// A complex number (f64 parts) +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct C { pub re: f64, pub im: f64 } +impl C { + pub const ZERO: C = C { re: 0.0, im: 0.0 }; + pub const ONE: C = C { re: 1.0, im: 0.0 }; + #[inline] pub fn new(re: f64, im: f64) -> C { C { re, im } } + #[inline] pub fn mul(self, o: C) -> C { C { re: self.re * o.re - self.im * o.im, im: self.re * o.im + self.im * o.re } } + #[inline] pub fn add(self, o: C) -> C { C { re: self.re + o.re, im: self.im + o.im } } + #[inline] pub fn conj(self) -> C { C { re: self.re, im: -self.im } } + #[inline] pub fn norm2(self) -> f64 { self.re * self.re + self.im * self.im } + /// e^{i t} + #[inline] pub fn cis(t: f64) -> C { C { re: m::cos(t), im: m::sin(t) } } +} + +/// The gate set: enough for Ising-type programs and QAOA layers. Angles in radians; RX(t) = exp(-i t X / 2), +/// RZ(t) = exp(-i t Z / 2), RZZ(t) = exp(-i t Z⊗Z / 2). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Gate { H(usize), X(usize), Z(usize), Rx(usize, f64), Rz(usize, f64), Cnot(usize, usize), Cz(usize, usize), Rzz(usize, usize, f64) } +impl Gate { + /// The gate as one canonical text line (angles at 6 decimals): what `circuit_text` joins and the circuit digest hashes + pub fn text(&self) -> String { + match *self { + Gate::H(a) => format!("h {a}"), Gate::X(a) => format!("x {a}"), Gate::Z(a) => format!("z {a}"), + Gate::Rx(a, t) => format!("rx {a} {}", f6(t)), Gate::Rz(a, t) => format!("rz {a} {}", f6(t)), + Gate::Cnot(a, b) => format!("cnot {a} {b}"), Gate::Cz(a, b) => format!("cz {a} {b}"), Gate::Rzz(a, b, t) => format!("rzz {a} {b} {}", f6(t)), + } + } + /// The gate's matrix on its own qubits (2x2, or 4x4 on (a, b) with basis index bit_a + 2 bit_b), row-major + pub fn matrix(&self) -> Vec { + let (r, z) = (core::f64::consts::FRAC_1_SQRT_2, C::ZERO); + let half = |t: f64| (C::new(m::cos(t / 2.0), 0.0), m::sin(t / 2.0)); + match *self { + Gate::H(_) => vec![C::new(r, 0.0), C::new(r, 0.0), C::new(r, 0.0), C::new(-r, 0.0)], + Gate::X(_) => vec![z, C::ONE, C::ONE, z], + Gate::Z(_) => vec![C::ONE, z, z, C::new(-1.0, 0.0)], + Gate::Rx(_, t) => { let (c, s) = half(t); vec![c, C::new(0.0, -s), C::new(0.0, -s), c] } + Gate::Rz(_, t) => vec![C::cis(-t / 2.0), z, z, C::cis(t / 2.0)], + Gate::Cnot(..) => { let mut m = vec![z; 16]; for (i, j) in [(0, 0), (1, 3), (2, 2), (3, 1)] { m[i * 4 + j] = C::ONE; } m } + Gate::Cz(..) => { let mut m = vec![z; 16]; for i in 0..4 { m[i * 5] = if i == 3 { C::new(-1.0, 0.0) } else { C::ONE }; } m } + Gate::Rzz(_, _, t) => { let mut m = vec![z; 16]; for i in 0..4 { let same = (i & 1) == (i >> 1); m[i * 5] = C::cis(if same { -t / 2.0 } else { t / 2.0 }); } m } + } + } +} + +/// max |U†U - I| over the entries of a gate's matrix +pub fn unitarity_error(g: &Gate) -> f64 { + let m = g.matrix(); let d = if m.len() == 4 { 2 } else { 4 }; let mut e: f64 = 0.0; + for i in 0..d { for j in 0..d { + let mut s = C::ZERO; for k in 0..d { s = s.add(m[k * d + i].conj().mul(m[k * d + j])); } + let want = if i == j { 1.0 } else { 0.0 }; e = e.max((s.re - want).abs()).max(s.im.abs()); } } + e +} + +/// A number at 6 decimals as the canonical text writes it (`-0` written `0`) +fn f6(x: f64) -> String { let s = format!("{x:.6}"); if s.trim_start_matches('-').bytes().all(|b| b == b'0' || b == b'.') { "0.000000".into() } else { s } } + +/// One step of a circuit: a gate, a diagonal phase layer exp(-i g E) (`Register::phase`), or a mixer layer on every variable +/// (`Register::mix`) +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Op { Gate(Gate), Phase(f64), Mix(f64), Oracle(f64), Diffuse(f64) } +impl Op { + /// One canonical text line (angles at 6 decimals) + pub fn text(&self) -> String { match self { Op::Gate(g) => g.text(), Op::Phase(g) => format!("phase {}", f6(*g)), Op::Mix(t) => format!("mix {}", f6(*t)), Op::Oracle(p) => format!("oracle {}", f6(*p)), Op::Diffuse(p) => format!("diffuse {}", f6(*p)) } } +} + +/// The mixer every round-2 layer uses, by name (the receipt's `engine.mixer`) +pub const MIXER: &str = "complete-graph"; + +/// Round 2: how variables with d allowed values are packed into qubits. Variable i (d_i >= 1 values) takes b_i = ceil(log2 d_i) +/// qubits, packed in variable order (its code is bits o_i..o_i + b_i of the basis index); code c < d_i is its c-th value, codes +/// c >= d_i are unused (d = 3: code 11) and keep amplitude 0 for the whole evolution. +#[derive(Clone, Debug, PartialEq)] +pub struct Encoding { pub levels: Vec } +impl Encoding { + /// Qubits for d values: ceil(log2 d) (0 for d = 1) + pub fn bits(d: usize) -> usize { assert!(d >= 1, "a variable needs at least one value"); (usize::BITS - (d - 1).leading_zeros()) as usize } + /// Every variable two-valued (round 1's programs) + pub fn two_valued(q: usize) -> Encoding { Encoding { levels: vec![2; q] } } + /// The encoded qubits, sum of ceil(log2 d_i) + pub fn qubits(&self) -> usize { self.levels.iter().map(|&d| Encoding::bits(d)).sum() } + /// Per variable its first qubit + pub fn offsets(&self) -> Vec { let mut o = 0; self.levels.iter().map(|&d| { let r = o; o += Encoding::bits(d); r }).collect() } + /// Variable i's code in basis index x + pub fn code(&self, x: usize, i: usize) -> usize { let o = self.offsets()[i]; (x >> o) & ((1usize << Encoding::bits(self.levels[i])) - 1) } + /// The codes of every variable in x, or None when one is unused + pub fn decode(&self, x: usize) -> Option> { + let mut o = 0; let mut v = Vec::with_capacity(self.levels.len()); + for &d in &self.levels { let b = Encoding::bits(d); let c = (x >> o) & ((1usize << b) - 1); if c >= d { return None; } v.push(c); o += b; } + Some(v) + } + /// The canonical text the encoding digest hashes: one line per variable, `var levels qubits ..` + pub fn text(&self) -> String { + let mut s = String::new(); for (i, (&d, o)) in self.levels.iter().zip(self.offsets()).enumerate() { s.push_str(&format!("var {i} levels {d} qubits {o}..{}\n", o + Encoding::bits(d))); } s + } +} + +/// The complete-graph mixer on d levels as a d x d matrix (row-major): exp(i t A) with A = J - I the adjacency of the complete +/// graph K_d. Closed form (J^2 = d J): e^{-it} (I + (e^{itd} - 1)/d J). d = 2 is exp(i t X) = RX(-2t); d = 1 is the identity. +pub fn mixer_matrix(d: usize, t: f64) -> Vec { + let (ph, c) = mixer_coef(d, t); (0..d * d).map(|k| { let off = c.mul(ph); if k / d == k % d { ph.add(off) } else { off } }).collect() +} +/// (e^{-it}, (e^{itd} - 1)/d) +fn mixer_coef(d: usize, t: f64) -> (C, C) { let e = C::cis(t * d as f64); (C::cis(-t), C::new((e.re - 1.0) / d as f64, e.im / d as f64)) } +/// max |U^dagger U - I| of the mixer on the whole 2^b block of a d-level variable (the mixer on the valid codes, the identity on +/// the unused ones) +pub fn mixer_unitarity_error(d: usize, t: f64) -> f64 { + let n = 1usize << Encoding::bits(d); let m = mixer_matrix(d, t); + let u = |i: usize, j: usize| if i < d && j < d { m[i * d + j] } else if i == j { C::ONE } else { C::ZERO }; + let mut e: f64 = 0.0; + for i in 0..n { for j in 0..n { let mut s = C::ZERO; for k in 0..n { s = s.add(u(k, i).conj().mul(u(k, j))); } + e = e.max((s.re - if i == j { 1.0 } else { 0.0 }).abs()).max(s.im.abs()); } } + e +} + +/// q qubits as 2^q complex amplitudes, and the ops applied (the circuit) +#[derive(Clone, Debug)] +pub struct Register { q: usize, amp: Vec, ops: Vec } +impl Register { + /// |0...0> on q qubits (q <= Q_MAX_HOST); the amplitudes take exactly 16 * 2^q bytes of heap + pub fn new(q: usize) -> Register { + assert!(q <= Q_MAX_HOST, "an amplitude register has at most {Q_MAX_HOST} qubits on the host"); + let mut amp = vec![C::ZERO; 1usize << q]; amp[0] = C::ONE; + let r = Register { q, amp, ops: vec![] }; + assert_eq!(r.bytes(), AMP_BYTES << q); r + } + /// Round 2: the uniform superposition over the valid codes of every variable, 1/sqrt(prod d_i) on each valid basis state and + /// 0 on the rest (for an all-two-valued encoding this is H on every qubit) + pub fn uniform(enc: &Encoding) -> Register { + let mut r = Register::new(enc.qubits()); let a = 1.0 / m::sqrt(enc.levels.iter().map(|&d| d as f64).product::()); + for (x, v) in r.amp.iter_mut().enumerate() { *v = if enc.decode(x).is_some() { C::new(a, 0.0) } else { C::ZERO }; } + r + } + pub fn qubits(&self) -> usize { self.q } + /// The amplitudes' bytes: 16 * 2^q + pub fn bytes(&self) -> usize { self.amp.len() * core::mem::size_of::() } + pub fn amplitudes(&self) -> &[C] { &self.amp } + pub fn ops(&self) -> &[Op] { &self.ops } + /// The gates among the ops + pub fn gates(&self) -> Vec { self.ops.iter().filter_map(|o| if let Op::Gate(g) = o { Some(*g) } else { None }).collect() } + /// The circuit as text, one op per line (angles at 6 decimals) + pub fn circuit_text(&self) -> String { let mut s = String::new(); for o in &self.ops { s.push_str(&o.text()); s.push('\n'); } s } + + fn one(&mut self, a: usize, m: [C; 4]) { + let bit = 1usize << a; + for i in 0..self.amp.len() { if i & bit != 0 { continue; } let (x, y) = (self.amp[i], self.amp[i | bit]); + self.amp[i] = m[0].mul(x).add(m[1].mul(y)); self.amp[i | bit] = m[2].mul(x).add(m[3].mul(y)); } + } + /// Apply one gate (logged in the circuit) + pub fn apply(&mut self, g: Gate) { + let q = self.q; let ok = |a: usize| assert!(a < q, "qubit {a} out of range ({q} qubits)"); + match g { + Gate::H(a) | Gate::X(a) | Gate::Z(a) | Gate::Rx(a, _) | Gate::Rz(a, _) => { ok(a); let m = g.matrix(); self.one(a, [m[0], m[1], m[2], m[3]]); } + Gate::Cnot(c, t) => { ok(c); ok(t); assert_ne!(c, t); let (bc, bt) = (1usize << c, 1usize << t); + for i in 0..self.amp.len() { if i & bc != 0 && i & bt == 0 { self.amp.swap(i, i | bt); } } } + Gate::Cz(a, b) => { ok(a); ok(b); assert_ne!(a, b); let m = (1usize << a) | (1usize << b); + for i in 0..self.amp.len() { if i & m == m { self.amp[i] = C::new(-self.amp[i].re, -self.amp[i].im); } } } + Gate::Rzz(a, b, t) => { ok(a); ok(b); assert_ne!(a, b); let (same, diff) = (C::cis(-t / 2.0), C::cis(t / 2.0)); + for i in 0..self.amp.len() { let p = if ((i >> a) & 1) == ((i >> b) & 1) { same } else { diff }; self.amp[i] = p.mul(self.amp[i]); } } + } + self.ops.push(Op::Gate(g)); + } + /// Round 2: the general diagonal phase layer, a_x <- e^{-i g E(x)} a_x for every basis state with a finite E(x) (E = -log w + /// of its plan); E(x) = NaN marks an unused code, left untouched (its amplitude is 0). O(2^q), any order of interaction. + pub fn phase(&mut self, e: &[f64], g: f64) { + assert_eq!(e.len(), self.amp.len(), "one energy per basis state"); + for (a, &ex) in self.amp.iter_mut().zip(e) { if !ex.is_nan() { *a = C::cis(-g * ex).mul(*a); } } + self.ops.push(Op::Phase(g)); + } + /// Round 2: the mixer layer, the complete-graph mixer exp(i t A_d) on the valid codes of every variable (`mixer_matrix`), + /// the identity on its unused codes: block-diagonal, O(d) per block by the closed form. + pub fn mix(&mut self, enc: &Encoding, t: f64) { + assert_eq!(enc.qubits(), self.q, "the encoding's qubits"); + let mut buf: Vec = vec![]; + for (&d, o) in enc.levels.iter().zip(enc.offsets()) { + if d < 2 { continue; } let b = Encoding::bits(d); let mask = ((1usize << b) - 1) << o; let (ph, c) = mixer_coef(d, t); + for x in 0..self.amp.len() { if x & mask != 0 { continue; } + buf.clear(); let mut s = C::ZERO; for k in 0..d { let a = self.amp[x | (k << o)]; buf.push(a); s = s.add(a); } let cs = c.mul(s); + for (k, &a) in buf.iter().enumerate() { self.amp[x | (k << o)] = ph.mul(a.add(cs)); } } + } + self.ops.push(Op::Mix(t)); + } + /// Round 4: the oracle, a_x <- e^{i phi} a_x on the basis states marked good (phi = pi: the sign flip) + pub fn oracle(&mut self, good: &[bool], phi: f64) { + assert_eq!(good.len(), self.amp.len(), "one mark per basis state"); let w = C::cis(phi); + for (a, &g) in self.amp.iter_mut().zip(good) { if g { *a = w.mul(*a); } } + self.ops.push(Op::Oracle(phi)); + } + /// Round 4: the diffusion about a stored state psi, I - (1 - e^{i phi}) |psi> f64 { self.amp.iter().zip(e).filter(|(_, x)| x.is_nan()).map(|(a, _)| a.norm2()).sum() } + /// The Born probabilities |a_x|^2 + pub fn probabilities(&self) -> Vec { self.amp.iter().map(|a| a.norm2()).collect() } + /// Exact marginals P(qubit i = 1), one per qubit (no sampling) + pub fn marginals(&self) -> Vec { + let mut m = vec![0.0; self.q]; + for (x, a) in self.amp.iter().enumerate() { let p = a.norm2(); for (i, mi) in m.iter_mut().enumerate() { if x >> i & 1 == 1 { *mi += p; } } } + m + } + /// Round 2: exact marginals per variable and level, P(variable i has code c), concatenated in variable order (sum d_i entries) + pub fn level_marginals(&self, enc: &Encoding) -> Vec { + let base: Vec = enc.levels.iter().scan(0, |s, &d| { let r = *s; *s += d; Some(r) }).collect(); + let mut m = vec![0.0; enc.levels.iter().sum()]; + for (x, a) in self.amp.iter().enumerate() { if let Some(v) = enc.decode(x) { let p = a.norm2(); for (i, c) in v.into_iter().enumerate() { m[base[i] + c] += p; } } } + m + } + /// One measurement of every qubit by the Born rule: the basis index, drawn with Philox4x32(seed, stream 0) + pub fn measure(&self, seed: u64) -> usize { + let u = Philox4x32::new(seed, 0).f64(); let mut c = 0.0; + for (x, a) in self.amp.iter().enumerate() { c += a.norm2(); if u < c { return x; } } + self.amp.iter().rposition(|a| a.norm2() > 0.0).unwrap_or(0) + } + /// The canonical state: one line per amplitude, `re im` at 6 decimals + pub fn canonical(&self) -> String { let mut s = String::new(); for a in &self.amp { s.push_str(&f6(a.re)); s.push(' '); s.push_str(&f6(a.im)); s.push('\n'); } s } + /// sha256 of the canonical state + pub fn digest(&self) -> [u8; 32] { sha256(self.canonical().as_bytes()) } +} + +/// An Ising program over q two-valued variables: log w(x) = c + sum_i a_i z_i + sum_(i, pub b: Vec<(usize, usize, f64)> } +impl Ising { + /// The program's log weight at basis index x, without the constant + pub fn logw(&self, x: usize) -> f64 { + let z = |i: usize| if x >> i & 1 == 0 { 1.0 } else { -1.0 }; + self.a.iter().enumerate().map(|(i, a)| a * z(i)).sum::() + self.b.iter().map(|&(i, j, b)| b * z(i) * z(j)).sum::() + } + /// E(x) = -log w(x) over the 2^q basis states (the phase layer's input) + pub fn energies(&self) -> Vec { (0..1usize << self.q).map(|x| -self.logw(x)).collect() } +} + +/// A transverse-field sweep: `steps` Trotter steps over total time `time`, s = (k + 1/2) / steps at step k, +/// H(s) = -(1 - s) sum_i A_i + s E (A_i the complete-graph mixer of variable i; E = -log w), started from the uniform +/// superposition over the valid codes +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Schedule { pub steps: usize, pub time: f64 } +impl Schedule { + /// The canonical text the schedule digest hashes + pub fn text(&self) -> String { format!("transverse-field linear steps {} time {}\n", self.steps, f6(self.time)) } + pub fn name(&self) -> String { format!("tf-linear-{}x{}", self.steps, f6(self.time)) } +} +/// The tier's fixed default schedule (rounds 1 and 2; schedules are a later round's study) +pub const DEFAULT_SCHEDULE: Schedule = Schedule { steps: 16, time: 4.0 }; +/// Round 5: the longer sweep (`engine.schedule: tf-linear-64x16`): 4x the default's steps and time, so about 4x its layers +pub const LONG_SCHEDULE: Schedule = Schedule { steps: 64, time: 16.0 }; +/// Round 5: the two layers (gamma, beta) of `engine.schedule: qaoa-p2`, fixed by a grid search on a held-out third of the P0 +/// corpus programs (the largest median separation on the decisions the thermal tier is indifferent on, subject to agreement +/// >= 90 % on the others; docs/rfc/0011-amplitude-register.md, "Schedules and the indifferent set"), then frozen: gamma 0.500000, +/// beta 0.500000, then gamma 1.500000, beta 0.250000 +pub const QAOA_P2: [(f64, f64); 2] = [(0.5, 0.5), (1.5, 0.25)]; +/// Round 5: the names `engine.schedule` accepts (the first is the default) +pub const SCHEDULE_NAMES: [&str; 3] = ["tf-linear-16x4", "tf-linear-64x16", "qaoa-p2"]; +/// Round 5: the evolution the tier runs on each component: a transverse-field sweep or QAOA layers +#[derive(Clone, Debug, PartialEq)] +pub enum Evolution { Sweep(Schedule), Qaoa(Vec<(f64, f64)>) } +impl Evolution { + /// The evolution of a name in `SCHEDULE_NAMES` (None for any other name) + pub fn named(name: &str) -> Option { + match name { "tf-linear-16x4" => Some(Evolution::Sweep(DEFAULT_SCHEDULE)), "tf-linear-64x16" => Some(Evolution::Sweep(LONG_SCHEDULE)), + "qaoa-p2" => Some(Evolution::Qaoa(QAOA_P2.to_vec())), _ => None } + } + /// The register after the evolution from the uniform start + pub fn run(&self, enc: &Encoding, e: &[f64]) -> Register { match self { Evolution::Sweep(s) => sweep(enc, e, s), Evolution::Qaoa(p) => qaoa_on(enc, e, p) } } + /// The canonical text the schedule digest hashes (a sweep's is `Schedule::text`, unchanged) + pub fn text(&self) -> String { + match self { Evolution::Sweep(s) => s.text(), + Evolution::Qaoa(p) => { let mut t = format!("qaoa layers {}\n", p.len()); for &(g, b) in p { t.push_str(&format!("gamma {} beta {}\n", f6(g), f6(b))); } t } } + } + /// The receipt's `schedule_name` (a sweep's is `Schedule::name`, unchanged) + pub fn name(&self) -> String { + match self { Evolution::Sweep(s) => s.name(), Evolution::Qaoa(p) if p[..] == QAOA_P2[..] => "qaoa-p2".into(), Evolution::Qaoa(p) => format!("qaoa-p{}-lab", p.len()) } + } +} + +/// The bar on the Born mass of the unused codes, checked after every layer (it is 0 by construction; never renormalised) +pub const INVALID_MASS_MAX: f64 = 1e-15; +fn checked(r: &Register, e: &[f64]) { let m = r.invalid_mass(e); assert!(m <= INVALID_MASS_MAX, "unused-code mass {m:e} after a layer"); } + +/// Round 2: the sweep `s` on an encoding and its energies (one per basis state, NaN on unused codes): the uniform start over +/// the valid codes, then per step the phase layer (g = dt s) and the mixer layer (t = dt (1 - s)) +pub fn sweep(enc: &Encoding, e: &[f64], s: &Schedule) -> Register { + let mut r = Register::uniform(enc); let dt = s.time / s.steps as f64; + for k in 0..s.steps { let x = (k as f64 + 0.5) / s.steps as f64; r.phase(e, dt * x); checked(&r, e); r.mix(enc, dt * (1.0 - x)); checked(&r, e); } + r +} +/// Round 2: QAOA on an encoding: the uniform start, then per layer (gamma, beta) the phase layer and the mixer layer +pub fn qaoa_on(enc: &Encoding, e: &[f64], params: &[(f64, f64)]) -> Register { + let mut r = Register::uniform(enc); + for &(g, b) in params { r.phase(e, g); checked(&r, e); r.mix(enc, b); checked(&r, e); } + r +} +/// The sweep on an Ising program (every variable two-valued): the same phase and mixer layers as `sweep` +pub fn evolve_ising(p: &Ising, s: &Schedule) -> Register { sweep(&Encoding::two_valued(p.q), &p.energies(), s) } +/// QAOA with p layers on an Ising program: the same layers as `qaoa_on` +pub fn qaoa(p: &Ising, layers: usize, params: &[(f64, f64)]) -> Register { + assert_eq!(params.len(), layers, "one (gamma, beta) per layer"); qaoa_on(&Encoding::two_valued(p.q), &p.energies(), params) +} + +/// Round 4: what `amplify_exact` did: the iterations (standard ones + the final phase-matched one), the final step's two +/// phases (oracle, diffusion; radians), the Born mass on the good states before and after +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Amplified { pub iterations: usize, pub phases: (f64, f64), pub mass_before: f64, pub mass_after: f64 } +/// The bar on the good states' mass after `amplify_exact` (asserted) +pub const AMPLIFIED_MASS_MIN: f64 = 1.0 - 1e-12; +/// Round 4: exact amplitude amplification of the good states (`good(x)` per basis index; an unused code is never good) about +/// the register's current state psi, in place of a projection. With a0 = the good mass of psi and theta = asin(sqrt(a0)), +/// k = floor(pi / (4 theta) - 1/2) standard iterations (oracle phase pi, diffusion phase pi about psi) bring the state to angle +/// (2k + 1) theta <= pi/2 in the plane of |good> (psi's normalised good part) and |bad>; one final iteration with phases +/// (phi, varphi) chosen so that its bad component is 0 lands on |good> exactly (Long's exact search, one matched step). Every +/// step stays in that plane, so the result is e^{i chi} |good>: the good states keep psi's relative amplitudes, the Born +/// probabilities are psi's restricted to the good states and divided by a0 -- the projection's numbers, by a unitary +/// evolution. a0 = 1: nothing to do (0 iterations); a0 = 0: None (nothing to amplify). Round 5: a landing whose mass after is +/// below 1 - 1e-12 (rounding drift over many iterations at a tiny a0, measured with the long sweep) is also None, so the tier +/// declines to the default tiers instead of panicking; the register is then left mid-rotation and must not be read. +/// Round 7: psi is renormalised once before the standard iterations, when there are any (its squared norm summed with +/// compensation; a NaN mass declines like a miss). The sweep's layers +/// leave it 1 + eps, a larger eps the more layers; the diffusion about such a psi is not unitary (|D v|^2 = |v|^2 + 4 eps ||^2), +/// so the error grows with every iteration, linearly in k: that, not the rotation, was the ~1e-12 shortfall of the long sweep's +/// 165 corpus programs (k in the hundreds at a tiny a0), and the same eps of the other sign is round 4's drift above 1. +pub fn amplify_exact(r: &mut Register, good: impl Fn(usize) -> bool) -> Option { + let g: Vec = (0..r.amp.len()).map(&good).collect(); let mass = |r: &Register| r.amp.iter().zip(&g).filter(|(_, &x)| x).map(|(a, _)| a.norm2()).sum::(); + let a0 = mass(r); if !(a0 > 0.0) { return None; } + if a0 >= 1.0 - 1e-15 { return Some(Amplified { iterations: 0, phases: (0.0, 0.0), mass_before: a0, mass_after: a0 }); } + let pi = core::f64::consts::PI; let k_of = |a0: f64| m::floor(pi / (4.0 * m::asin(m::sqrt(a0))) - 0.5).max(0.0) as usize; + // renormalise only when there are standard iterations to drift over (k >= 1, so a0 <= 1/4): with none, the one matched step + // is round 4's, unchanged, and a mass near 1 cannot round above 1. The receipt's mass before stays the swept state's (a0); + // the rotation uses the renormalised state's. + let mass_before = a0; + let a0 = if k_of(a0) == 0 { a0 } else { let u = 1.0 / m::sqrt(compensated_norm2(&r.amp)); for a in r.amp.iter_mut() { *a = C::new(a.re * u, a.im * u); } mass(r) }; + let psi = r.amp.clone(); + let k = k_of(a0); + for _ in 0..k { r.oracle(&g, pi); r.diffuse(&psi, pi); } + // the final step: the state is +-(al |good> + be |bad>), al, be >= 0; with s = sin theta, c = cos theta, S_psi(varphi) + // S_f(phi) leaves be - (1 - e^{i varphi}) c (al s e^{i phi} + be c) on |bad>: it vanishes for + // cos phi = -(be / al) cos(2 theta) / sin(2 theta) (|cos phi| < 1 for this k) and 1 - e^{i varphi} = be / (c (al s e^{i phi} + be c)) + let a = mass(r).min(1.0); let (al, be) = (m::sqrt(a), m::sqrt((1.0 - a).max(0.0))); let (s, c) = (m::sqrt(a0), m::sqrt(1.0 - a0)); + let cphi = (-(be / al) * (1.0 - 2.0 * a0) / (2.0 * s * c)).clamp(-1.0, 1.0); let phi = m::acos(cphi); + let w = C::new(al * s * m::cos(phi) + be * c, al * s * m::sin(phi)); let den = w.norm2() * c; // z = be / (c w) = be conj(w) / (c |w|^2) + let z = C::new(be * w.re / den, -be * w.im / den); let vphi = m::atan2(-z.im, 1.0 - z.re); + r.oracle(&g, phi); r.diffuse(&psi, vphi); + let m = mass(r); if !(m >= AMPLIFIED_MASS_MIN) { return None; } // a NaN declines too + Some(Amplified { iterations: k + 1, phases: (phi, vphi), mass_before, mass_after: m }) +} + +/// Round 7: sum |a_x|^2 with Neumaier's compensation (the plain running sum of 2^16 terms can be off by ~1e-14, the size of the +/// drift the renormalisation removes) +fn compensated_norm2(amp: &[C]) -> f64 { + let (mut s, mut c) = (0.0f64, 0.0f64); + for a in amp { let x = a.norm2(); let t = s + x; c += if s >= x { (s - t) + x } else { (x - t) + s }; s = t; } // s, x >= 0 + s + c +} + +/// sha256 (FIPS 180-4), one shot +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 msg = data.to_vec(); msg.push(0x80); while msg.len() % 64 != 56 { msg.push(0); } msg.extend_from_slice(&((data.len() as u64) * 8).to_be_bytes()); + for blk in msg.chunks(64) { + let mut w = [0u32; 64]; + for i in 0..16 { w[i] = u32::from_be_bytes([blk[4 * i], blk[4 * i + 1], blk[4 * i + 2], blk[4 * i + 3]]); } + for i in 16..64 { let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3); let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16].wrapping_add(s0).wrapping_add(w[i - 7]).wrapping_add(s1); } + let mut v = h; + for i in 0..64 { let s1 = v[4].rotate_right(6) ^ v[4].rotate_right(11) ^ v[4].rotate_right(25); let ch = (v[4] & v[5]) ^ (!v[4] & v[6]); + let t1 = v[7].wrapping_add(s1).wrapping_add(ch).wrapping_add(K[i]).wrapping_add(w[i]); + let s0 = v[0].rotate_right(2) ^ v[0].rotate_right(13) ^ v[0].rotate_right(22); let mj = (v[0] & v[1]) ^ (v[0] & v[2]) ^ (v[1] & v[2]); let t2 = s0.wrapping_add(mj); + v = [t1.wrapping_add(t2), v[0], v[1], v[2], v[3].wrapping_add(t1), v[4], v[5], v[6]]; } + for i in 0..8 { h[i] = h[i].wrapping_add(v[i]); } + } + let mut out = [0u8; 32]; for i in 0..8 { out[4 * i..4 * i + 4].copy_from_slice(&h[i].to_be_bytes()); } out +} +/// Lower-case hex +pub fn hex(b: &[u8]) -> String { b.iter().map(|x| format!("{x:02x}")).collect() } + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn every_gate_is_unitary_to_1e_12() { + for t in [0.0, 0.3, -1.7, 2.5, 3.14159, 10.0] { + for g in [Gate::H(0), Gate::X(0), Gate::Z(0), Gate::Rx(0, t), Gate::Rz(0, t), Gate::Cnot(0, 1), Gate::Cz(0, 1), Gate::Rzz(0, 1, t)] { assert!(unitarity_error(&g) < 1e-12, "{g:?}"); } + } + } + #[test] + fn memory_is_16_times_2_to_the_q() { for q in [0, 1, 8, 12] { assert_eq!(Register::new(q).bytes(), 16 << q); } } + #[test] + fn bell_state_interference_and_norm() { + let mut r = Register::new(2); r.apply(Gate::H(0)); r.apply(Gate::Cnot(0, 1)); + let p = r.probabilities(); assert!((p[0] - 0.5).abs() < 1e-15 && (p[3] - 0.5).abs() < 1e-15 && p[1] < 1e-30 && p[2] < 1e-30); + // H Z H = X; two H in a row cancel (destructive interference on |1>) + let mut r = Register::new(1); r.apply(Gate::H(0)); r.apply(Gate::H(0)); assert!(r.probabilities()[1] < 1e-30); + let mut r = Register::new(1); r.apply(Gate::H(0)); r.apply(Gate::Z(0)); r.apply(Gate::H(0)); assert!((r.probabilities()[1] - 1.0).abs() < 1e-15); + let p = Ising { q: 3, a: vec![0.4, -0.2, 0.1], b: vec![(0, 1, 0.6), (1, 2, -0.3)] }; + let r = evolve_ising(&p, &DEFAULT_SCHEDULE); assert!((r.probabilities().iter().sum::() - 1.0).abs() < 1e-12); + let r = qaoa(&p, 2, &[(0.4, 0.7), (0.2, 0.3)]); assert!((r.probabilities().iter().sum::() - 1.0).abs() < 1e-12); + } + #[test] + fn three_level_codes_stay_empty_and_the_mixer_is_unitary() { + for d in 1..=8 { for t in [0.0, 0.3, -1.7, 2.5, 10.0] { assert!(mixer_unitarity_error(d, t) < 1e-12, "d {d} t {t}"); } } + assert_eq!((Encoding::bits(1), Encoding::bits(2), Encoding::bits(3), Encoding::bits(4), Encoding::bits(5)), (0, 1, 2, 2, 3)); + // d = 2: the complete-graph mixer is RX(-2t) exactly + let m = mixer_matrix(2, 0.7); let g = Gate::Rx(0, -1.4).matrix(); for k in 0..4 { assert!((m[k].re - g[k].re).abs() < 1e-15 && (m[k].im - g[k].im).abs() < 1e-15); } + // a three-level variable, a two-valued flag and a three-level variable with a pair table favouring the diagonal + let enc = Encoding { levels: vec![3, 2, 3] }; assert_eq!(enc.qubits(), 5); + let lw = |v: &[usize]| [0.4, -0.2, 0.9][v[0]] + if v[1] == 1 { 0.3 } else { 0.0 } + if v[0] == v[2] { 1.1 } else { 0.0 } + if v[1] == 1 && v[2] == 2 { -0.5 } else { 0.0 }; + let e: Vec = (0..32).map(|x| enc.decode(x).map_or(f64::NAN, |v| -lw(&v))).collect(); + let r = sweep(&enc, &e, &DEFAULT_SCHEDULE); assert!(r.invalid_mass(&e) == 0.0); + assert!((r.probabilities().iter().sum::() - 1.0).abs() < 1e-12); + let lm = r.level_marginals(&enc); assert_eq!(lm.len(), 8); for (o, d) in [(0, 3), (3, 2), (5, 3)] { assert!((lm[o..o + d].iter().sum::() - 1.0).abs() < 1e-12); } + let r = qaoa_on(&enc, &e, &[(0.4, 0.7), (0.9, 0.2)]); assert!(r.invalid_mass(&e) == 0.0); + // the uniform start over the valid codes: 18 of 32 basis states + assert_eq!(Register::uniform(&enc).probabilities().iter().filter(|&&p| p > 0.0).count(), 18); + } + #[test] + fn exact_amplification_lands_on_the_projection() { + // a three-level, a two-valued and a three-level variable; good = a rule over the plans; masses from 0.5 down to ~1e-3 + let enc = Encoding { levels: vec![3, 2, 3] }; + let lw = |v: &[usize]| [0.4, -0.2, 0.9][v[0]] + if v[1] == 1 { 0.3 } else { 0.0 } + if v[0] == v[2] { 1.1 } else { 0.0 } + if v[1] == 1 && v[2] == 2 { -0.5 } else { 0.0 }; + let e: Vec = (0..32).map(|x| enc.decode(x).map_or(f64::NAN, |v| -lw(&v))).collect(); + for rule in 0..18usize { + let good = |x: usize| enc.decode(x).is_some_and(|v| (v[0] * 6 + v[1] * 3 + v[2] + rule) % 18 < 1 + rule % 9); + let sw = sweep(&enc, &e, &DEFAULT_SCHEDULE); let p = sw.probabilities(); let a0: f64 = (0..32).filter(|&x| good(x)).map(|x| p[x]).sum(); + let mut r = sw.clone(); let am = amplify_exact(&mut r, good).unwrap(); assert!(am.mass_after >= AMPLIFIED_MASS_MIN && (am.mass_before - a0).abs() < 1e-15); + let q = r.probabilities(); for x in 0..32 { let want = if good(x) { p[x] / a0 } else { 0.0 }; assert!((q[x] - want).abs() < 1e-12, "rule {rule} x {x}: {} vs {want}", q[x]); } + assert_eq!(r.invalid_mass(&e), 0.0); + } + let mut r = Register::uniform(&enc); assert!(amplify_exact(&mut r, |_| false).is_none()); + let mut r = Register::uniform(&enc); assert_eq!(amplify_exact(&mut r, |x| enc.decode(x).is_some()).unwrap().iterations, 0); + } + /// Round 5: the named evolutions: the default name is `sweep` with the default schedule (the same register, text and name), + /// the long sweep has 4x the layers, qaoa-p2 two layers with the frozen angles; an unknown name is None + #[test] + fn named_schedules_run_what_they_name() { + let enc = Encoding { levels: vec![3, 2] }; let e: Vec = (0..8).map(|x| enc.decode(x).map_or(f64::NAN, |v| -[0.4, -0.2, 0.9][v[0]] - 0.3 * v[1] as f64)).collect(); + let d = Evolution::named(SCHEDULE_NAMES[0]).unwrap(); assert_eq!(d, Evolution::Sweep(DEFAULT_SCHEDULE)); + assert_eq!(d.run(&enc, &e).amplitudes(), sweep(&enc, &e, &DEFAULT_SCHEDULE).amplitudes()); assert_eq!((d.text(), d.name()), (DEFAULT_SCHEDULE.text(), DEFAULT_SCHEDULE.name())); + let l = Evolution::named(SCHEDULE_NAMES[1]).unwrap(); assert_eq!(l.run(&enc, &e).circuit_text().lines().count(), 4 * d.run(&enc, &e).circuit_text().lines().count()); + let q = Evolution::named(SCHEDULE_NAMES[2]).unwrap(); assert_eq!(q.name(), "qaoa-p2"); assert_eq!(q.run(&enc, &e).amplitudes(), qaoa_on(&enc, &e, &QAOA_P2).amplitudes()); + assert_eq!(q.text(), "qaoa layers 2\ngamma 0.500000 beta 0.500000\ngamma 1.500000 beta 0.250000\n"); assert!(Evolution::named("tf-linear-8x2").is_none()); + } + /// Round 7: a psi whose squared norm is off by 1e-12 (a long sweep leaves ~1e-14) and one good state of 2^14, so about a + /// hundred iterations: without the renormalisation the landing drifts by ~2 k eps (~2e-10); with it the mass stays within + /// 1e-12 of 1 and the good state's probability is 1 + #[test] + fn a_landing_after_a_hundred_iterations_keeps_its_mass() { + let enc = Encoding::two_valued(14); let mut r = Register::uniform(&enc); let f = (1.0 + 1e-12f64).sqrt(); + for a in r.amp.iter_mut() { *a = C::new(a.re * f, a.im * f); } + let am = amplify_exact(&mut r, |x| x == 5).unwrap(); assert!(am.iterations > 90, "{}", am.iterations); + assert!((am.mass_after - 1.0).abs() <= 1e-12, "mass after {:e}", am.mass_after - 1.0); assert!((r.probabilities()[5] - 1.0).abs() <= 1e-12); + assert!((compensated_norm2(r.amplitudes()) - 1.0).abs() <= 1e-12); + } + #[test] + fn measure_and_digest_are_deterministic() { + let p = Ising { q: 4, a: vec![0.5, 0.1, -0.3, 0.2], b: vec![(0, 3, 0.4)] }; + let (r1, r2) = (evolve_ising(&p, &DEFAULT_SCHEDULE), evolve_ising(&p, &DEFAULT_SCHEDULE)); + assert_eq!(r1.digest(), r2.digest()); assert_eq!(r1.measure(7), r2.measure(7)); assert_eq!(r1.circuit_text(), r2.circuit_text()); + assert_eq!(hex(&sha256(b"abc")), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + assert_eq!(f6(-0.0000001), "0.000000"); + } +} diff --git a/probbit-decide/src/lib.rs b/probbit-decide/src/lib.rs index c490c7d..add429c 100644 --- a/probbit-decide/src/lib.rs +++ b/probbit-decide/src/lib.rs @@ -21,6 +21,7 @@ use probbit_core::Philox4x32; use std::collections::HashMap; pub mod oracle; pub mod ir; +pub mod amp; /// Tasks up to which `Problem::logw` uses the pairwise loop. R19.8 re-measured (`examples/logw_threshold.rs`, 6 workers, groups /// of 4 / 8, median of 7 alternating rounds, load ~8.6): linear / pairwise ns per call = 1.52 / 1.25 at 96 tasks, 0.96 / 0.92 at diff --git a/probbit-nostd/src/lib.rs b/probbit-nostd/src/lib.rs index 92e9ddc..8df3cd7 100644 --- a/probbit-nostd/src/lib.rs +++ b/probbit-nostd/src/lib.rs @@ -127,6 +127,7 @@ pub trait FloatExt: Sized { fn exp(self) -> Self; fn exp_m1(self) -> Self; fn ln(self) -> Self; fn ln_1p(self) -> Self; fn log2(self) -> Self; fn log10(self) -> Self; fn sqrt(self) -> Self; fn powf(self, e: Self) -> Self; fn powi(self, n: i32) -> Self; fn tanh(self) -> Self; fn cos(self) -> Self; fn sin(self) -> Self; fn floor(self) -> Self; fn ceil(self) -> Self; fn round(self) -> Self; fn trunc(self) -> Self; fn fract(self) -> Self; + fn asin(self) -> Self; fn acos(self) -> Self; fn atan2(self, x: Self) -> Self; } /// `powi` as compiler-rt's `__powidf2` computes it (what `std`'s `f64::powi` lowers to), not `pow`: same bits as the host. fn powi64(mut a: f64, mut b: i32) -> f64 { @@ -141,6 +142,7 @@ impl FloatExt for f64 { fn tanh(self) -> f64 { libm::tanh(self) } fn cos(self) -> f64 { libm::cos(self) } fn sin(self) -> f64 { libm::sin(self) } fn floor(self) -> f64 { libm::floor(self) } fn ceil(self) -> f64 { libm::ceil(self) } fn round(self) -> f64 { libm::round(self) } fn trunc(self) -> f64 { libm::trunc(self) } fn fract(self) -> f64 { self - libm::trunc(self) } + fn asin(self) -> f64 { libm::asin(self) } fn acos(self) -> f64 { libm::acos(self) } fn atan2(self, x: f64) -> f64 { libm::atan2(self, x) } } impl FloatExt for f32 { fn exp(self) -> f32 { libm::expf(self) } fn exp_m1(self) -> f32 { libm::expm1f(self) } fn ln(self) -> f32 { libm::logf(self) } @@ -149,6 +151,7 @@ impl FloatExt for f32 { fn tanh(self) -> f32 { libm::tanhf(self) } fn cos(self) -> f32 { libm::cosf(self) } fn sin(self) -> f32 { libm::sinf(self) } fn floor(self) -> f32 { libm::floorf(self) } fn ceil(self) -> f32 { libm::ceilf(self) } fn round(self) -> f32 { libm::roundf(self) } fn trunc(self) -> f32 { libm::truncf(self) } fn fract(self) -> f32 { self - libm::truncf(self) } + fn asin(self) -> f32 { libm::asinf(self) } fn acos(self) -> f32 { libm::acosf(self) } fn atan2(self, x: f32) -> f32 { libm::atan2f(self, x) } } /// What a `no_std` module adds with one line (`use std::prelude::rust_2021::*;`) to see the names `std`'s prelude gives it. diff --git a/probbit-persona/src/persona.rs b/probbit-persona/src/persona.rs index a5eb19c..20f045b 100644 --- a/probbit-persona/src/persona.rs +++ b/probbit-persona/src/persona.rs @@ -173,7 +173,11 @@ struct Hist { id: String, of: String, streak: bool, window: usize, cap: usize, s 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 } +struct Eng { op: String, sweeps: usize, polish_sweeps: usize, exact_limit: u64, chains: usize, twin: bool, positional: bool, yields: bool, + /// RFC 0011 (research): `tier: amplitude` (off when absent) and its `q_max` (default 12); round 4: `amplify` (default false) + amp: bool, q_max: usize, amplify: bool, + /// round 5: `schedule` (a name in `amp::SCHEDULE_NAMES`; absent = the default sweep, the receipt unchanged) + schedule: Option } /// bounded learning (docs/persona.md §2.8): `from` = the reward flag, then (optional) the correction flag; the traits whose learned /// deltas move; the step and total caps (natural-log weights); `credit` = action-addressed feedback, when declared #[derive(Clone)] @@ -528,8 +532,8 @@ fn build_base(doc: &Json) -> R> { for (i, hb) in list(get(kv, "habits"), "habits")?.iter().enumerate() { habits.push(habit_spec(hb, &ix("habits", i), &inputs, &history, &vars, &steps)?); } 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"], &[])?; + 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, amp: false, q_max: 12, amplify: false, schedule: None }; + if let Some(e) = get_t(kv, "engine") { let ekv = keys(e, "engine", &["op", "sweeps", "polish_sweeps", "exact_limit", "chains", "twin", "values", "on_conflict", "tier", "q_max", "amplify", "schedule", "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(), @@ -537,11 +541,17 @@ fn build_base(doc: &Json) -> R> { "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" => {} + "tier" => { need(x.as_str() == Some("amplitude"), &p, "amplitude (the one optional tier; absent = the default tiers)")?; eng.amp = true; } + "q_max" => eng.q_max = num(Some(x), &p, Some(1.0), Some(16.0), true)? as usize, + "amplify" => eng.amplify = if let Json::Bool(b) = x { *b } else { return Err(perr(&p, "true | false")) }, + "schedule" => eng.schedule = Some(x.as_str().filter(|n| SCHEDULE_NAMES.contains(n)).ok_or_else(|| perr(&p, "tf-linear-16x4 | tf-linear-64x16 | qaoa-p2"))?.to_string()), "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, } } } + if eng.amplify && !eng.amp { return Err(perr(&at("engine", "amplify"), "true needs tier: amplitude (amplification acts on the amplitude register)")); } + if eng.schedule.is_some() && !eng.amp { return Err(perr(&at("engine", "schedule"), "needs tier: amplitude (the schedule is the amplitude register's evolution)")); } // line let lkv: &[(String, Json)] = match get_t(kv, "line") { Some(l) => keys(l, "line", &["max_tokens", "order", "prefix", "why_past", "block", "comment"], &[])?, None => &[] }; // RFC 0008 E: `line.block` puts the engine-rendered block in every stance document (a persona with a bound or a coupling has it) @@ -2745,7 +2755,9 @@ struct Entry { level: String, p: f64, odds: Vec<(String, f64)>, released: bool, /// 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, lift: Vec<(String, f64)> } + line: String, why: String, agenda: Option>, state_digest: String, held: Vec, timing: Option, lift: Vec<(String, f64)>, + /// RFC 0011: the amplitude tier's receipt (an object) or why it declined (a string); None without the flag + amp: 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()); @@ -2754,7 +2766,8 @@ impl Out { 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))])), + ("engine".into(), { let mut e = vec![("verdict".into(), self.verdict.clone()), ("tier".into(), self.tier.clone()), ("program".into(), s(&self.program))]; + match &self.amp { Some(Json::Obj(r)) => e.extend(r.iter().cloned()), Some(w) => e.push(("amplitude".into(), w.clone())), None => {} } Json::Obj(e) }), ("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))])), @@ -2935,13 +2948,27 @@ fn decode(p: &Persona, st: &State, program: &str, meta: &Meta, doc: &Json, confl 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, lift: meta.lift.clone() }; + 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, lift: meta.lift.clone(), amp: doc.get("amplitude").cloned() }; (out, ns) } // ------------------------------------------------------------------------------------------------------------ turns fn solve(p: &Persona, st: &State, prog: &Json, eng: Engine, calls: &mut usize) -> Json { - *calls += 1; let was = crate::trace::enter(crate::trace::Phase::Solve); let r = eng(prog, &flags(p, st)); crate::trace::enter(was); r + *calls += 1; let was = crate::trace::enter(crate::trace::Phase::Solve); + let r = if p.eng.amp { amplitude_tier(prog, p.eng.q_max, p.eng.amplify, p.eng.schedule.as_deref()).unwrap_or_else(|why| declined(eng(prog, &flags(p, st)), &why)) } else { eng(prog, &flags(p, st)) }; + crate::trace::enter(was); r +} +/// RFC 0011 (research): the amplitude tier on a program (`run::amplitude`); Err(why) when it does not take it +#[cfg(feature = "decide")] +fn amplitude_tier(prog: &Json, q_max: usize, amplify: bool, schedule: Option<&str>) -> Result { + match schedule.and_then(probbit_decide::amp::Evolution::named) { Some(ev) => crate::run::amplitude_with(prog, q_max, amplify, &ev), None => crate::run::amplitude(prog, q_max, amplify) } +} +#[cfg(not(feature = "decide"))] +fn amplitude_tier(_: &Json, _: usize, _: bool, _: Option<&str>) -> Result { Err("this build has no amplitude tier (feature decide)".into()) } +/// RFC 0011 round 5 (research): the names `engine.schedule` accepts (`probbit_decide::amp::SCHEDULE_NAMES`; checked by a test) +const SCHEDULE_NAMES: [&str; 3] = ["tf-linear-16x4", "tf-linear-64x16", "qaoa-p2"]; +/// The existing tiers' answer, with the receipt of why the amplitude tier did not run +fn declined(mut doc: Json, why: &str) -> Json { if let Json::Obj(v) = &mut doc { v.push(("amplitude".into(), Json::Str(format!("declined: {why}")))); } doc } 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 @@ -4837,6 +4864,101 @@ mod tests { for n in [0, 1, 55, 56, 57, 63, 64, 65, 119, 120, 128, 300] { let one = sha256(&data[..n]); for piece in [1, 3, 64, 100] { let mut h = Sha256::new(); for c in data[..n].chunks(piece) { h.update(c); } assert_eq!(h.finish(), one, "{n} bytes in pieces of {piece}"); } } } + /// RFC 0011 (research): `engine.tier: amplitude` on a persona of two-valued traits answers from the simulated register (its + /// receipt in the stance document, habits held, deterministic, a strand that verifies); round 2: three-valued traits are + /// answered too (two qubits each, one register per component), a component over `q_max` declines, the existing tiers answer + /// and the receipt says why; absent, nothing changes + #[cfg(feature = "decide")] + #[test] + fn the_amplitude_tier_answers_multi_level_programs_and_declines_past_q_max() { + use crate::strand::{self, Clock, Live}; + let text = include_str!("../../examples/persona/dispatch-policy.yaml").replace("\r\n", "\n").replace("engine:\n", "engine:\n tier: amplitude\n"); + let doc = crate::yaml::load(&text).ok().unwrap(); let p = build(&doc).unwrap(); let st = init(&p, Some(2), true, &run); + for raw in [r#"{"blast":0}"#, r#"{"blast":0.9}"#, r#"{"blast":0.3,"error":true}"#] { + let raw = json::parse(raw).unwrap(); let (a, _) = turn(&p, &st, &raw, false, &run, false).unwrap(); let (b, _) = turn(&p, &st, &raw, false, &run, false).unwrap(); + assert_eq!(canon(&a), canon(&b)); assert_eq!(g(&a, &["engine", "tier"]).as_str(), Some("amplitude"), "{}", canon(&a)); + let q = g(&a, &["engine", "qubits"]).as_f64().unwrap(); assert!((1.0..=4.0).contains(&q)); + for k in ["circuit", "schedule", "state"] { assert!(g(&a, &["engine", k]).as_str().is_some_and(is_digest), "{k}"); } + assert_eq!(g(&a, &["habits", "violations"]).as_f64(), Some(0.0)); assert_eq!(g(&a, &["status"]).as_str(), Some("ok")); } + let (mut live, header) = Live::start(p.clone(), &doc, st.clone(), Clock::Fixed, "probbit test"); let mut lines = vec![header]; + for (i, raw) in [r#"{"blast":0}"#, r#"{"blast":0.9}"#, r#"{"error":true}"#, r#"{"error":true}"#, r#"{"praise":true,"src":"human"}"#].iter().enumerate() { + let (_, l) = live.event(&json::parse(raw).unwrap(), &run).unwrap_or_else(|e| panic!("event {i}: {}", e.msg)); lines.push(l); } + let strand_text = lines.join("\n") + "\n"; assert!(strand::verify(&strand_text, &run).is_ok()); + // three-valued traits: answered (encoded qubits, the components, the encoding and the mixer in the receipt) + let q = build(&json::parse(&DOC.replace("LEARN", r#","engine":{"tier":"amplitude"}"#)).unwrap()).unwrap(); let s0 = init(&q, Some(3), true, &run); + let (d, _) = turn(&q, &s0, &inputs(false, false, false), false, &run, false).unwrap(); let (d2, _) = turn(&q, &s0, &inputs(false, false, false), false, &run, false).unwrap(); + assert_eq!(canon(&d), canon(&d2)); assert_eq!(g(&d, &["engine", "tier"]).as_str(), Some("amplitude"), "{}", canon(&d)); + assert_eq!(g(&d, &["engine", "mixer"]).as_str(), Some("complete-graph")); assert!(g(&d, &["engine", "encoding"]).as_str().is_some_and(is_digest)); + assert!(g(&d, &["engine", "qubits"]).as_f64().unwrap() >= 2.0); assert_eq!(g(&d, &["habits", "violations"]).as_f64(), Some(0.0)); + // a component past q_max: declined, the existing tier answers, the receipt says so + let q1 = build(&json::parse(&DOC.replace("LEARN", r#","engine":{"tier":"amplitude","q_max":1}"#)).unwrap()).unwrap(); + let (d, _) = turn(&q1, &init(&q1, Some(3), true, &run), &inputs(false, false, false), false, &run, false).unwrap(); + assert!(g(&d, &["engine", "amplitude"]).as_str().is_some_and(|w| w.starts_with("declined: ") && w.contains("encoded qubits, q_max 1")), "{}", canon(&d)); + assert_ne!(g(&d, &["engine", "tier"]).as_str(), Some("amplitude")); + // the tutor example (three-level traits, 60 encoded qubits in components of at most 10): answered, and its strand verifies + let text = include_str!("../../examples/persona/tutor.yaml").replace("\r\n", "\n").replace("engine:\n", "engine:\n tier: amplitude\n"); + let doc = crate::yaml::load(&text).ok().unwrap(); let p = build(&doc).unwrap(); let st = init(&p, Some(2), true, &run); + let (mut live, header) = Live::start(p.clone(), &doc, st, Clock::Fixed, "probbit test"); let mut lines = vec![header]; + for raw in [r#"{"elapsed_hours":1,"praise":true}"#, r#"{"elapsed_hours":2}"#] { let (o, l) = live.event(&json::parse(raw).unwrap(), &run).unwrap(); lines.push(l); + assert_eq!(g(&o, &["engine", "tier"]).as_str(), Some("amplitude"), "{}", canon(&o)); } + assert!(strand::verify(&(lines.join("\n") + "\n"), &run).is_ok()); + let plain = build(&json::parse(&DOC.replace("LEARN", "")).unwrap()).unwrap(); let (e, _) = turn(&plain, &init(&plain, Some(3), true, &run), &inputs(false, false, false), false, &run, false).unwrap(); + assert!(g(&e, &["engine", "amplitude"]).is_null() && g(&e, &["engine", "qubits"]).is_null()); + assert_eq!(err(build(&json::parse(&DOC.replace("LEARN", r#","engine":{"tier":"pbit"}"#)).unwrap())), "engine.tier: amplitude (the one optional tier; absent = the default tiers)"); + } + /// RFC 0011 round 4 (research): `engine.amplify: true` rotates each component's register onto its feasible plans by exact + /// amplitude amplification instead of projecting: the stance is the projection's (plan, odds, line), the receipt carries + /// `amplify` and feasible mass 1, the strand verifies; `amplify: false` is the round-3 behaviour; without the tier it is refused + #[cfg(feature = "decide")] + #[test] + fn amplify_lands_on_the_projection_and_logs_it() { + use crate::strand::{self, Clock, Live}; + let base = include_str!("../../examples/persona/tutor.yaml").replace("\r\n", "\n"); + let mk = |extra: &str| { let doc = crate::yaml::load(&base.replace("engine:\n", &format!("engine:\n tier: amplitude\n{extra}"))).ok().unwrap(); (build(&doc).unwrap(), doc) }; + let ((pj, _), (pf, _), (pa, doc)) = (mk(""), mk(" amplify: false\n"), mk(" amplify: true\n")); + let keep = |d: &Json| { let mut d = d.clone(); if let Json::Obj(v) = &mut d { v.retain(|(k, _)| !["persona", "engine", "state_digest"].contains(&k.as_str())); } canon(&d) }; + let (sj, sf, sa) = (init(&pj, Some(2), true, &run), init(&pf, Some(2), true, &run), init(&pa, Some(2), true, &run)); + for raw in [r#"{"elapsed_hours":1,"praise":true}"#, r#"{"elapsed_hours":2}"#, r#"{"elapsed_hours":1,"correction":true}"#] { + let raw = json::parse(raw).unwrap(); let (a, _) = turn(&pj, &sj, &raw, false, &run, false).unwrap(); let (f, _) = turn(&pf, &sf, &raw, false, &run, false).unwrap(); + let (b, _) = turn(&pa, &sa, &raw, false, &run, false).unwrap(); + assert_eq!(canon(&g(&a, &["engine"])), canon(&g(&f, &["engine"]))); assert!(g(&a, &["engine", "amplify"]).is_null()); + assert_eq!(keep(&a), keep(&b), "the stance with amplify is the projection's"); + assert_eq!(g(&b, &["engine", "feasible_mass"]).as_f64(), Some(1.0)); let am = g(&b, &["engine", "amplify"]); + let it = am.get("iterations").and_then(Json::as_arr).unwrap(); assert_eq!(it.len(), g(&b, &["engine", "components"]).as_arr().unwrap().len()); + assert!(it.iter().any(|x| x.as_f64().unwrap() >= 1.0), "{}", canon(&am)); assert_eq!(am.get("mass_after").and_then(Json::as_f64), Some(1.0)); + assert_eq!(am.get("mass_before").and_then(Json::as_f64), g(&a, &["engine", "feasible_mass"]).as_f64()); } + let (mut live, header) = Live::start(pa.clone(), &doc, sa, Clock::Fixed, "probbit test"); let mut lines = vec![header]; + for raw in [r#"{"elapsed_hours":1,"praise":true}"#, r#"{"elapsed_hours":2}"#] { let (_, l) = live.event(&json::parse(raw).unwrap(), &run).unwrap(); lines.push(l); } + assert!(strand::verify(&(lines.join("\n") + "\n"), &run).is_ok()); + let e = err(build(&crate::yaml::load(&base.replace("engine:\n", "engine:\n amplify: true\n")).ok().unwrap())); + assert!(e.starts_with("engine.amplify: true needs tier: amplitude"), "{e}"); + } + /// RFC 0011 round 5 (research): `engine.schedule` names the register's evolution: the default name is the absent key (the + /// same documents, byte for byte), the other two name themselves in the receipt and their strands verify, an unknown name and + /// the key without the tier are refused; the name list is the decide crate's + #[cfg(feature = "decide")] + #[test] + fn the_schedule_key_names_the_evolution() { + use crate::strand::{self, Clock, Live}; + assert_eq!(SCHEDULE_NAMES, probbit_decide::amp::SCHEDULE_NAMES); + let base = include_str!("../../examples/persona/tutor.yaml").replace("\r\n", "\n"); + let mk = |extra: &str| { let doc = crate::yaml::load(&base.replace("engine:\n", &format!("engine:\n tier: amplitude\n amplify: true\n{extra}"))).ok().unwrap(); (build(&doc).unwrap(), doc) }; + let (p0, _) = mk(""); let (pd, _) = mk(" schedule: tf-linear-16x4\n"); let s0 = init(&p0, Some(2), true, &run); let sd = init(&pd, Some(2), true, &run); + let raw = json::parse(r#"{"elapsed_hours":1,"praise":true}"#).unwrap(); + let (a, _) = turn(&p0, &s0, &raw, false, &run, false).unwrap(); let (b, _) = turn(&pd, &sd, &raw, false, &run, false).unwrap(); + assert_eq!(canon(&g(&a, &["engine"])), canon(&g(&b, &["engine"]))); assert_eq!(g(&a, &["engine", "schedule_name"]).as_str(), Some("tf-linear-16x4.000000")); + for (name, shown) in [("tf-linear-64x16", "tf-linear-64x16.000000"), ("qaoa-p2", "qaoa-p2")] { + let (p, doc) = mk(&format!(" schedule: {name}\n")); let st = init(&p, Some(2), true, &run); + let (d, _) = turn(&p, &st, &raw, false, &run, false).unwrap(); assert_eq!(g(&d, &["engine", "schedule_name"]).as_str(), Some(shown)); + assert_eq!(g(&d, &["engine", "feasible_mass"]).as_f64(), Some(1.0)); + let (mut live, header) = Live::start(p.clone(), &doc, st, Clock::Fixed, "probbit test"); let mut lines = vec![header]; + for r in [r#"{"elapsed_hours":1,"praise":true}"#, r#"{"elapsed_hours":2}"#] { let (_, l) = live.event(&json::parse(r).unwrap(), &run).unwrap(); lines.push(l); } + assert!(strand::verify(&(lines.join("\n") + "\n"), &run).is_ok(), "{name}"); } + let e = err(build(&crate::yaml::load(&base.replace("engine:\n", "engine:\n tier: amplitude\n schedule: tf-linear-8x2\n")).ok().unwrap())); + assert_eq!(e, "engine.schedule: tf-linear-16x4 | tf-linear-64x16 | qaoa-p2"); + let e = err(build(&crate::yaml::load(&base.replace("engine:\n", "engine:\n schedule: qaoa-p2\n")).ok().unwrap())); + assert!(e.starts_with("engine.schedule: needs tier: amplitude"), "{e}"); + } /// The boxed setup API is the by-value one: the same persona, state, header and individual #[test] fn the_boxed_setup_is_the_by_value_setup() { diff --git a/probbit-persona/src/run.rs b/probbit-persona/src/run.rs index 5da7f3d..6e43966 100644 --- a/probbit-persona/src/run.rs +++ b/probbit-persona/src/run.rs @@ -508,3 +508,94 @@ pub fn exact_cap(xms: Option, reached: bool) -> Vec<(&'static str, Json)> { } /// Per item, the error bar the gate compares with `tv_tol`: z x max(MCSE short, MCSE long) (`Gate::released_tasks`). pub fn item_bars(g: &Gate) -> Vec { g.sig_tv.iter().zip(&g.sig_tv_long).map(|(&s, &l)| GATE.z * if s.is_nan() || l.is_nan() { f64::NAN } else { s.max(l) }).collect() } + +/// RFC 0011 (research): the `amplitude` tier, an exact simulated qubit register run on a CPU (`probbit_decide::amp`). Round 2: +/// a variable with d candidate values (allowed and clamp) takes ceil(log2 d) qubits (`amp::Encoding`; the unused codes keep +/// amplitude 0), and the program's log weight enters as one general diagonal phase layer (E = -log w per basis state), so +/// pair tables between multi-level variables need no decomposition. The program is split into its connected components +/// (two variables are linked by a pair or by sharing a capacity rule, as the `components` tier splits it) and each component +/// runs its own register: the phase is a sum over components and the mixer acts per variable, so the joint state is exactly +/// the product of the components' states. Eligible when every variable has an allowed value and the largest component has at +/// most `q_max` encoded qubits; Err(why) otherwise, and the caller runs the existing tiers. Each register runs the default +/// sweep from the uniform superposition over its valid codes; the rules are a projection: the Born probabilities of the +/// plans that break one are set to 0 and the rest renormalised (per component; the feasible mass is the product). Plan = per +/// component its most probable feasible plan (probabilities at 6 decimals, then the lowest basis index); marginals exact. The +/// receipt: qubits (encoded, summed), the components' qubits, the digests of the encoding, the circuit, the schedule and the +/// final amplitudes (6 decimals; the components' texts joined), the mixer's and the schedule's names, the feasible mass. +/// Round 4, `amplify` (`engine.amplify: true`): no projection; each component's swept state is rotated onto its feasible plans +/// by exact amplitude amplification (`amp::amplify_exact`: the rules as the oracle, the diffusion about the swept state, one +/// phase-matched final iteration), so the register itself holds feasible mass 1 (up to 1e-12) and the plan and marginals are +/// read from its Born probabilities; the receipt adds `amplify` (iterations and the final phases per component, the mass +/// before and after) and `feasible_mass` is the mass after. The relative amplitudes inside the feasible plans are the swept +/// state's, so the numbers equal the projection's (docs/rfc/0011-amplitude-register.md, "Amplification"). +#[cfg(feature = "decide")] +pub fn amplitude(prog: &Json, q_max: usize, amplify: bool) -> Result { amplitude_with(prog, q_max, amplify, &probbit_decide::amp::Evolution::Sweep(probbit_decide::amp::DEFAULT_SCHEDULE)) } +/// Round 5: `amplitude` with the evolution `ev` in place of the default sweep (`engine.schedule`; the receipt names it) +#[cfg(feature = "decide")] +pub fn amplitude_with(prog: &Json, q_max: usize, amplify: bool, ev: &probbit_decide::amp::Evolution) -> Result { + use probbit_decide::amp; + let p = from_json(prog).map_err(|e| format!("program {}: {}", e.path, e.msg))?; let m = &p.m; let (n, k) = (m.n, m.k); + let cand: Vec> = (0..n).map(|i| (0..k).filter(|&v| m.ok(i, v)).collect()).collect(); + if let Some(i) = cand.iter().position(Vec::is_empty) { return Err(format!("{} has no allowed value", p.vars[i])); } + // the components: pairs and capacity rules link variables + let mut par: Vec = (0..n).collect(); + fn find(par: &mut [usize], mut x: usize) -> usize { while par[x] != x { par[x] = par[par[x]]; x = par[x]; } x } + fn join(par: &mut [usize], a: usize, b: usize) { let (a, b) = (find(par, a), find(par, b)); if a != b { par[a.max(b)] = a.min(b); } } + for q in &m.pairs { join(&mut par, q.i, q.j); } + for c in &m.caps { if let Some(&(i0, _)) = c.members.first() { for &(i, _) in &c.members { join(&mut par, i0, i); } } } + let mut slot = vec![usize::MAX; n]; let mut mems: Vec> = vec![]; + for i in 0..n { let r = find(&mut par, i); if slot[r] == usize::MAX { slot[r] = mems.len(); mems.push(vec![]); } mems[slot[r]].push(i); } + let comp_of: Vec = (0..n).map(|i| slot[find(&mut par, i)]).collect(); + let encs: Vec = mems.iter().map(|mem| amp::Encoding { levels: mem.iter().map(|&i| cand[i].len()).collect() }).collect(); + let qs: Vec = encs.iter().map(amp::Encoding::qubits).collect(); let qtop = qs.iter().copied().max().unwrap_or(0); + if qtop > q_max.min(amp::Q_MAX_HOST) { return Err(format!("a component of {qtop} encoded qubits, q_max {q_max}")); } + let s = ev; let r6 = |x: f64| (x * 1e6).round() / 1e6; + let (mut enc_t, mut circ_t, mut state_t) = (String::new(), String::new(), String::new()); + let mut bp = vec![0usize; n]; let mut mg = vec![0.0; n * k]; let mut mass_all = 1.0; + let (mut am_it, mut am_ph, mut am_before) = (vec![], vec![], 1.0); + for (c, mem) in mems.iter().enumerate() { + // the component's sub-model: its variables, pairs and capacity rules (local indices), as the components tier builds it + let mut loc = vec![usize::MAX; n]; for (q, &i) in mem.iter().enumerate() { loc[i] = q; } + let nn = mem.len(); let mut h = Vec::with_capacity(nn * k); let mut al = Vec::with_capacity(nn * k); + for &i in mem { h.extend_from_slice(&m.h[i * k..i * k + k]); al.extend_from_slice(&m.allowed[i * k..i * k + k]); } + let pairs: Vec = m.pairs.iter().filter(|q| comp_of[q.i] == c).map(|q| Pair { i: loc[q.i], j: loc[q.j], c: q.c.clone() }).collect(); + let caps: Vec = m.caps.iter().filter(|q| q.members.first().is_some_and(|&(i, _)| comp_of[i] == c)) + .map(|q| Cap { members: q.members.iter().map(|&(i, v)| (loc[i], v)).collect(), limit: q.limit, weights: q.weights.clone() }).collect(); + let sub = Model::new(nn, k, h, al, mem.iter().map(|&i| m.clamp[i]).collect(), pairs, caps).map_err(|e| format!("component {c}: {e}"))?; + let enc = &encs[c]; let nb = 1usize << qs[c]; + let plan_of = |codes: &[usize]| -> Vec { codes.iter().zip(mem).map(|(&cd, &i)| cand[i][cd]).collect() }; + let e: Vec = (0..nb).map(|x| enc.decode(x).map_or(f64::NAN, |cd| -sub.logw(&plan_of(&cd)))).collect(); + let mut reg = s.run(enc, &e); + if amplify { + let ok: Vec = (0..nb).map(|x| enc.decode(x).is_some_and(|cd| sub.violations(&plan_of(&cd)) == 0)).collect(); + let Some(a) = amp::amplify_exact(&mut reg, |x| ok[x]) else { + // round 5: None after a rotation (good mass near 1) = the landing missed its bar: decline, never panic + let m: f64 = reg.probabilities().iter().zip(&ok).filter(|(_, &g)| g).map(|(p, _)| p).sum(); + return Err(if m > 0.5 { format!("component {c}: amplification missed its 1 - 1e-12 landing") } else { format!("component {c}: no feasible plan has weight in the register") }); }; + let pr = reg.probabilities(); + let best = (0..nb).fold(usize::MAX, |bx, x| if enc.decode(x).is_some() && pr[x] > 0.0 && (bx == usize::MAX || r6(pr[x]) > r6(pr[bx])) { x } else { bx }); + for (&i, v) in mem.iter().zip(plan_of(&enc.decode(best).expect("a plan with weight has valid codes"))) { bp[i] = v; } + for (x, &w) in pr.iter().enumerate() { if let Some(cd) = enc.decode(x) { for (&i, v) in mem.iter().zip(plan_of(&cd)) { mg[i * k + v] += w; } } } + mass_all *= a.mass_after; am_before *= a.mass_before; am_it.push(num(a.iterations as f64)); + am_ph.push(Json::Arr(if a.iterations == 0 { vec![] } else { vec![num(r6(a.phases.0)), num(r6(a.phases.1))] })); + for t in [&mut enc_t, &mut circ_t, &mut state_t] { t.push_str(&format!("component {c}\n")); } + enc_t.push_str(&enc.text()); circ_t.push_str(®.circuit_text()); state_t.push_str(®.canonical()); + continue; + } + let pr: Vec = reg.probabilities().iter().enumerate().map(|(x, &w)| match enc.decode(x) { Some(cd) if sub.violations(&plan_of(&cd)) == 0 => w, _ => 0.0 }).collect(); + let mass: f64 = pr.iter().sum(); if mass <= 0.0 || !mass.is_finite() { return Err(format!("component {c}: no feasible plan has weight in the register")); } + let best = (0..nb).fold(usize::MAX, |bx, x| if pr[x] > 0.0 && (bx == usize::MAX || r6(pr[x] / mass) > r6(pr[bx] / mass)) { x } else { bx }); + for (&i, v) in mem.iter().zip(plan_of(&enc.decode(best).expect("a feasible plan has valid codes"))) { bp[i] = v; } + for (x, &w) in pr.iter().enumerate() { if w > 0.0 { for (&i, v) in mem.iter().zip(plan_of(&enc.decode(x).expect("valid"))) { mg[i * k + v] += w / mass; } } } + mass_all *= mass; + for t in [&mut enc_t, &mut circ_t, &mut state_t] { t.push_str(&format!("component {c}\n")); } + enc_t.push_str(&enc.text()); circ_t.push_str(®.circuit_text()); state_t.push_str(®.canonical()); + } + let d = |t: &[u8]| jstr(&format!("sha256:{}", amp::hex(&::sha256(t)))); + let amp_r = if amplify { vec![("amplify", obj(vec![("iterations", Json::Arr(am_it)), ("phases", Json::Arr(am_ph)), ("mass_before", num(r6(am_before))), ("mass_after", num(r6(mass_all)))]))] } else { vec![] }; + Ok(obj(vec![("verdict", jstr("exact")), ("tier", jstr("amplitude")), ("plan", plan(&p, &bp)), ("plan_logw", num(m.logw(&bp))), ("violations", num(m.violations(&bp) as f64)), + ("marginals", marginals(&p, &mg)), ("released", ids(&p, &vec![true; n], true)), ("escalated", Json::Arr(vec![])), + ("amplitude", obj(vec![("qubits", num(qs.iter().sum::() as f64)), ("components", Json::Arr(qs.iter().map(|&q| num(q as f64)).collect())), + ("encoding", d(enc_t.as_bytes())), ("mixer", jstr(amp::MIXER)), ("circuit", d(circ_t.as_bytes())), ("schedule", d(s.text().as_bytes())), ("schedule_name", jstr(&s.name())), + ("state", d(state_t.as_bytes())), ("feasible_mass", num(r6(mass_all)))].into_iter().chain(amp_r).collect()))])) +}