diff --git a/CHANGELOG.md b/CHANGELOG.md index f3201e8..a5eea8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project are documented here. The format follows ## 0.10.1 - unreleased +### Added +- **Rest and load** (RFC 0002 part 2, docs/rfcs/0002-neuromodulators-and-body.md): two opt-in blocks. `rest` keeps a + pressure S that rises over active hours and falls over idle gaps; an idle gap at S ≥ threshold brings a rest line, its own + strand line, that scales every learned weight by `downscale` (prune below `prune`), keeping their order, and never shares an + event with feedback (`pressure.pending`). Each learned trait's surprise gate is left unchanged. `load` keeps a slow L + raised by aversive evidence (learning flags, declared senses, credit expiries) and a held mood, relaxing with its half-life, + capped; it enters the field as the engine-owned number input `load_level` (box [0, cap]), refused when sent. State + `pressure: {s, pending}` and `load: {l}`; refusals W-R1 and L-R1. `live verify` recomputes rest lines. bench/body P6 + (r0-r5) and P7 (l1-l5) measured by the engine for the first time (numbers in the PR). A persona without the blocks gives + byte-identical documents. + ### Changed - A new learner's surprise gate starts closed (docs/persona.md §2.13, RFC 0002 Design B): v = 0, r = r_ref, g = 1, so the opening step equals the fixed rate's (0.10.0 started at g = 2.5 and stepped 1.67× at the opening feedback, bench/body s3), and diff --git a/docs/rfcs/0002-neuromodulators-and-body.md b/docs/rfcs/0002-neuromodulators-and-body.md index 2a5bccc..d262b50 100644 --- a/docs/rfcs/0002-neuromodulators-and-body.md +++ b/docs/rfcs/0002-neuromodulators-and-body.md @@ -423,6 +423,20 @@ a floor exempting reinforced levels gave back a third of the gain and protected **Public:** "During idle time it scales its learned weights down in proportion, keeping their order." +Decided by the implementation (0.10.1): +- **Feedback** for the deferral is a learning flag on (`learning.from`), or with a drives block any goal signal in the event. + The pressure steps on every event (the rule above, h = the stamped hours); when an idle gap finds S ≥ θ and the event + carries feedback, `pressure.pending` becomes true and the line comes before the next event without feedback, whatever its + gap. A rest line clears `pending` and leaves S to the pressure step of its event. +- **The line** is `{"prev", "rest": {downscale, prune, pressure, learned, gate}, "state"}`: ρ and ε, S before the line, the + learned weights after it, and the digest of the state after it. It is written only directly before its event's line, which + chains to it; `live verify` recomputes it from the state and the event and refuses a missing, extra or changed rest line + (exit 1). The turn applies the same downscale before step 1, so `persona turn` without a strand gives the same stance. +- **The surprise gate at a rest line:** each learned trait's v, r̂, g and counts are left unchanged (the gate tracks + feedback and a rest line carries none); the line says so per learned trait (`gate: {: "unchanged: v, r, g and + counts kept"}`). The credit and the open decisions are left as they are too: a rest line scales the learned weights only. +- The prune compares the rounded value: Δ ← round(ρΔ, 6), then 0 when |round(ρΔ, 6)| < ε. + **`load` (simulation).** Wear accumulates when responses repeat, fail to adapt or fail to shut off (McEwen 1998; McEwen 2006), and can leave a lasting set-point deviation (Koob & Le Moal 2001). @@ -443,6 +457,18 @@ interacting channels and is out of scope. State `load: {l}`. **Public:** "Repeated aversive events raise a slow load that relaxes over days." +Decided by the implementation (0.10.1): +- **Evidence a_t:** a learning flag on adds its weight × 1; `sense_` (a declared sense) adds its weight × this event's + reading (a flag sense true = 1, false = 0, a number sense its value, no reading 0); `expired` adds its weight × the number + of open decisions that expire at this event (t − n − 1 ≥ 4 × the credit half-life, which the patience gene scales; 0 + without a credit block). +- **The hold** reads the mood accumulator at the mood's last level, as it stands before this event (the load step runs + before compile, where this event's mood evidence enters), so it lags the mood by one event on and off. +- **Lowering:** `load_level` is a number input (box [0, L_cap]) with the block's `effects` (ordinal shifts per unit of L), + added after the declared inputs; reserved only with the block, refused when the host sends it (`inputs.load_level`), left + out of the stance's `inputs`; the stance adds `load: {l}` (L after this event). The load step runs right after the pace + step. `load.from` with `expired` and no credit block is accepted and reads 0. + ## Invariants | invariant | statement | diff --git a/probbit-cli/src/live.rs b/probbit-cli/src/live.rs index ac827db..50f66b0 100644 --- a/probbit-cli/src/live.rs +++ b/probbit-cli/src/live.rs @@ -45,7 +45,7 @@ fn refusal(s: Status) -> InErr { 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 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 } /// 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) } @@ -65,7 +65,7 @@ impl Live { ("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); - (Live { p, st, clock, last: 0.0, prev, n: 0, status: Status::Active, controls: 0, checkpoints: 0 }, header) + (Live { p, st, clock, last: 0.0, prev, n: 0, status: Status::Active, controls: 0, checkpoints: 0, rests: 0 }, 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. @@ -81,9 +81,14 @@ impl Live { kv.push(("elapsed_hours".into(), Json::Num(elapsed))); let inputs = Json::Obj(kv); let (stance, ns) = persona::turn(&self.p, &self.st, &inputs, false, eng, false)?; + // 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 line = persona::canon(&Json::Obj(vec![("n".into(), Json::Num((self.n + 1) as f64)), ("inputs".into(), inputs), ("stance".into(), Json::Str(persona::sha(&stance))), - ("state".into(), Json::Str(ns.digest.clone())), ("prev".into(), Json::Str(self.prev.clone()))])); + ("state".into(), Json::Str(ns.digest.clone())), ("prev".into(), Json::Str(prev))])); 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`: @@ -115,17 +120,23 @@ pub fn control_line(prev: &str, what: &str, by: &str, reason: &str, at: &str) -> 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 } -fn kind(j: &Json) -> Kind { if j.get("control").is_some() { Kind::Control } else if j.get("checkpoint").is_some() { Kind::Checkpoint } else { Kind::Event } } +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": ..., "rest": ...}` -/// gives `rest`) or a field it does not know on a known one +/// 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 => &["inputs", "n", "prev", "stance", "state"], Kind::Control => &["at", "by", "control", "prev", "reason"], - Kind::Checkpoint => &["checkpoint", "prev", "stance", "state"] }; + 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 @@ -155,7 +166,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) } +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), 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)] @@ -185,7 +196,7 @@ impl Replay { 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()]); - Ok(Replay { live, header: h, doc, line: 1, from: None, last: None, checkpoint_ready: false, norms, at_cap, seen }) + Ok(Replay { live, header: h, doc, line: 1, from: None, last: None, checkpoint_ready: false, norms, at_cap, seen, 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 @@ -229,9 +240,14 @@ impl Replay { 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())); } - if j.get("prev").and_then(Json::as_str) != Some(live.head()) { return Err((i, PREV.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())); } @@ -248,7 +264,13 @@ impl Replay { 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 (stance, line) = live.event(ev, eng).map_err(|e| (i, read_err(e, "")))?; + 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::sha(&stance).as_str()) { return Err((i, "the stance differs".into())); } if j.get("state").and_then(Json::as_str) != Some(live.st.digest.as_str()) { return Err((i, "the state differs".into())); } @@ -259,6 +281,11 @@ impl Replay { 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); } } 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 pub fn summary(&self) -> Json { let (s, live) = (|x: &str| Json::Str(x.to_string()), &self.live); @@ -267,6 +294,10 @@ impl Replay { // 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); @@ -301,7 +332,7 @@ pub fn verify(text: &str, eng: Engine) -> Result { let replay = || -> Result { let mut r = Replay::open(lines.first().copied().unwrap_or(""))?; for l in lines.iter().skip(1) { r.step(l, eng)?; } - Ok(r.summary()) }; + 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 @@ -331,7 +362,7 @@ pub fn verify_from_checkpoint(text: &str, eng: Engine) -> Result 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)?; } - Ok(r.summary()) }; + 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) @@ -341,7 +372,7 @@ pub fn verify_keep(text: &str, eng: Engine, keep: &[u64]) -> Result<(Json, Vec<( 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)); } } } - Ok((r.summary(), kept, r.live.p.clone())) }; + 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, @@ -431,6 +462,10 @@ fn check_chain(text: &str) -> Result { } } } + 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. @@ -467,7 +502,7 @@ pub fn resume(text: &str, p: &Persona, st: State, clock: Clock) -> Result (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)]))); } - let mut lv = Live { p: q, st, clock, last: 0.0, prev: persona::digest_of(last), n, status: Status::Active, controls: 0, checkpoints: 0 }; + 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 }; 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) @@ -1028,8 +1063,8 @@ mod tests { assert!(verify_doc(t, &run).get("diverges").is_some()); }; let header = |f: &dyn Fn(&mut Json)| { let mut h = json::parse(&ls[0]).unwrap(); f(&mut h); persona::text(&h) }; let load = json::parse(r#"{"half_life_hours":72,"kappa":0.5,"theta":1,"cap":4,"from":{"criticism":1,"sense_tool_fail":1},"effects":{"caution":0.5}}"#).unwrap(); - // the header: a persona block (RFC 0002 part 2's load) and a field in a known block, each with the persona digest of its document - for (path, field) in [(vec!["document", "load"], "load"), (vec!["document", "learning", "pace"], "learning.pace")] { + // the header: a persona block a newer engine might add (`sleep`) and a field in a known block, each with the persona digest of its document + for (path, field) in [(vec!["document", "sleep"], "sleep"), (vec!["document", "learning", "pace"], "learning.pace")] { let h = header(&|h| { put(h, &path, load.clone()); let d = persona::sha(h.get("document").unwrap()); put(h, &["persona", "digest"], Json::Str(d)); }); let mut t: Vec = ls.clone(); t[0] = h; let t = rechain(t, 1); unsupported_at(&t, 1, field); @@ -1038,13 +1073,13 @@ mod tests { } // a header key, and a state field (the state's digest recomputed, as a writer's would be) let h = header(&|h| put(h, &["clock"], Json::Str("fixed".into()))); let mut t = ls.clone(); t[0] = h; unsupported_at(&rechain(t, 1), 1, "clock"); - let h = header(&|h| { put(h, &["state", "load"], Json::Num(0.0)); let mut s = h.get("state").unwrap().clone(); if let Json::Obj(v) = &mut s { v.retain(|(k, _)| k != "digest"); } + let h = header(&|h| { put(h, &["state", "sleep"], Json::Num(0.0)); let mut s = h.get("state").unwrap().clone(); if let Json::Obj(v) = &mut s { v.retain(|(k, _)| k != "digest"); } put(h, &["state", "digest"], Json::Str(persona::sha(&s))); }); - let mut t = ls.clone(); t[0] = h; unsupported_at(&rechain(t, 1), 1, "state.load"); - // a line kind (a rest line) at the end, and in the middle with the lines after it re-chained - let rest = |prev: &str| persona::canon(&json::parse(&format!(r#"{{"prev":"{prev}","rest":{{"rho":0.5}}}}"#)).unwrap()); - let mut t = ls.clone(); t.push(rest(&persona::digest_of(&ls[6]))); unsupported_at(&rechain(t, 7), 8, "rest"); - let mut mid = ls.clone(); mid.insert(3, rest("")); let mid = rechain(mid, 3); unsupported_at(&mid, 4, "rest"); + let mut t = ls.clone(); t[0] = h; unsupported_at(&rechain(t, 1), 1, "state.sleep"); + // a line kind (a `nap` line) at the end, and in the middle with the lines after it re-chained + let rest = |prev: &str| persona::canon(&json::parse(&format!(r#"{{"prev":"{prev}","nap":{{"rho":0.5}}}}"#)).unwrap()); + let mut t = ls.clone(); t.push(rest(&persona::digest_of(&ls[6]))); unsupported_at(&rechain(t, 7), 8, "nap"); + let mut mid = ls.clone(); mid.insert(3, rest("")); let mid = rechain(mid, 3); unsupported_at(&mid, 4, "nap"); // a field on an event line let mut t = ls.clone(); let mut j = json::parse(&t[4]).unwrap(); put(&mut j, &["why"], Json::Str("a newer field".into())); t[4] = persona::canon(&j); unsupported_at(&rechain(t, 5), 5, "why"); // tampered: a changed input diverges at its line; a removed line at the next line's prev; a changed known header value at line 1 @@ -1057,7 +1092,7 @@ mod tests { diverges_at(&mid.replacen(r#""rho":0.5"#, r#""rho":0.9"#, 1), 5); // `live --strand` (resume) and `live control` refuse to continue it, saying why (exit 2) let mut t = ls.clone(); t.push(rest(&persona::digest_of(&ls[6]))); let e = check_chain(&rechain(t, 7)).unwrap_err(); - assert_eq!(e.msg, "line 8: unsupported by this engine: rest"); + assert_eq!(e.msg, "line 8: unsupported by this engine: nap"); } /// The header holds the state as canonical JSON, so one individual starts one header whether it comes fresh from `init` @@ -1460,4 +1495,28 @@ mod tests { assert_eq!(fixed.event(&json::parse(r#"{"elapsed_hours":-1}"#).unwrap(), &run).unwrap_err().path, "event.elapsed_hours"); assert_eq!(fixed.n, 0, "a bad event changes nothing"); } + + /// RFC 0002 part 2: a rest line is its own strand line before its event; `verify` recomputes it, and refuses the strand + /// with it removed (the chain, or rechained: missing), edited, or with one where none is due + #[test] + fn verify_recomputes_rest_lines() { + let doc = json::parse(r#"{"probbit_persona":1,"identity":{"name":"R","version":"1","seed":0},"traits":[{"id":"approach","levels":["low","mid","high"]}], + "inputs":[{"id":"praise","kind":"flag"},{"id":"criticism","kind":"flag"}],"learning":{"from":["praise","criticism"],"traits":["approach"],"rate":0.5,"step_cap":0.2,"total_cap":1}, + "rest":{"rise_per_hour":0.3,"fall_per_hour":0.238,"gap_hours":4,"threshold":0.5,"downscale":0.5,"prune":0.05}}"#).unwrap(); + let p = persona::build(&doc).unwrap(); let st = persona::init(&p, Some(1), true, &run); + let (mut live, header) = Live::start(p, &doc, st, Clock::Fixed, "probbit test"); + let mut text = format!("{header}\n"); + for t in 0..12 { let ev = json::parse(&if t == 11 { r#"{"elapsed_hours":8}"#.to_string() } else { format!(r#"{{"elapsed_hours":1,"praise":{}}}"#, t > 0) }).unwrap(); + text.push_str(&live.event(&ev, &run).unwrap().1); text.push('\n'); } + let ls: Vec = text.lines().map(str::to_string).collect(); + let at = ls.iter().position(|l| l.starts_with(r#"{"prev":"#) && l.contains(r#","rest":{"#)).expect("a rest line"); assert_eq!(at, ls.len() - 2); + let ok = verify(&text, &run).unwrap(); assert_eq!(ok.get("rest_lines").and_then(Json::as_f64), Some(1.0), "{}", persona::canon(&ok)); + let mut t = ls.clone(); t.remove(at); assert_eq!(verify(&(t.join("\n") + "\n"), &run).unwrap_err().0, at + 1); + let mut t = ls.clone(); t.remove(at); let r = rechain(t, at); assert_eq!(verify(&r, &run).unwrap_err(), (at + 1, "a rest line is due before this event and missing".into())); + let mut t = ls.clone(); t[at] = t[at].replacen(r#""downscale":0.5"#, r#""downscale":0.6"#, 1); let r = rechain(t, at + 1); + assert_eq!(verify(&r, &run).unwrap_err(), (at + 1, "the rest line differs".into())); + let t = ls[..=at].join("\n") + "\n"; assert_eq!(verify(&t, &run).unwrap_err(), (at + 1, "a rest line must be followed by its event line".into())); + let mut t = ls.clone(); let extra = t[at].clone(); t.insert(3, extra); let r = rechain(t, 3); assert_eq!(verify(&r, &run).unwrap_err(), (4, "a rest line where none is due".into())); + } + } diff --git a/probbit-cli/src/persona.rs b/probbit-cli/src/persona.rs index 3f1301f..1a030a5 100644 --- a/probbit-cli/src/persona.rs +++ b/probbit-cli/src/persona.rs @@ -152,6 +152,17 @@ struct Pace { hl: f64, cap: f64, r0: f64, slow: f64, brisk: f64, effects: Vec<(S /// `dispositions` (RFC 0002 Design D): the spreads of the optimism, patience and volatility genes (0 = no gene) #[derive(Clone, Copy, Debug, PartialEq)] struct Disp { optimism: f64, patience: f64, volatility: f64 } +/// `rest` (RFC 0002 part 2): the pressure's rise and fall per active / idle hour, the idle gap in stamped hours, the pressure +/// threshold θ, the downscale ρ in [0.5, 1) and the prune ε of a rest line +#[derive(Clone, Debug, PartialEq)] +struct Rest { up: f64, down: f64, gap: f64, theta: f64, rho: f64, prune: f64 } +/// `load` (RFC 0002 part 2): L's half-life in stamped hours, κ, θ, L_cap, the aversive evidence's weights (a learning flag, a +/// declared sense as `sense_`, or `expired`), the ordinal shift per unit of L per trait or mood, and the optional hold (a +/// mood whose accumulator above `above` adds κ_h per stamped hour) +#[derive(Clone, Debug, PartialEq)] +struct Load { hl: f64, kappa: f64, theta: f64, cap: f64, from: Vec<(String, f64)>, effects: Vec<(String, f64)>, hold: Option<(String, f64, f64)> } +/// The input a load block lowers to (RFC 0002 part 2), reserved only with the block +const LOAD_ID: &str = "load_level"; /// The two inputs a pace block lowers to (RFC 0002 Design C), reserved only with the block const PACE_IDS: [&str; 2] = ["pace_up", "pace_down"]; /// Learned deltas or credits: per learned trait, one number per level (in `learning.traits` order) @@ -160,7 +171,7 @@ type Table = Vec<(String, Vec)>; pub struct Persona { pub name: String, pub version: String, pub seed: u64, pub digest: String, vars: Vec, steps: Vec, slots: Vec, say_order: bool, prefer: Eff, couplings: Vec<(String, String, Vec>)>, inputs: Vec, history: Vec, habits: Vec, eng: Eng, max_tokens: usize, order: Vec, prefix: String, learning: Option, drives: Option, reward: Option>)>>, senses: Option, why_past: Option, - surprise: Option, pace: Option, disp: Option } + surprise: Option, pace: Option, disp: Option, rest_b: Option, load: 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()) } @@ -174,6 +185,10 @@ impl Persona { pub fn has_needs(&self) -> bool { self.drives.as_ref().is_some_and(|d| d.needs.is_some()) } /// `pace_up` or `pace_down` with a pace block (RFC 0002 Design C): the engine sets them from R̄ fn is_pace(&self, id: &str) -> bool { self.pace.is_some() && PACE_IDS.contains(&id) } + /// `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 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") } @@ -277,11 +292,11 @@ 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 { - const TOP: [&str; 20] = ["probbit_persona", "identity", "traits", "moods", "couplings", "inputs", "history", "habits", "agenda", "engine", "line", "learning", "drives", "reward_from", "senses", "needs", - "surprise", "pace", "dispositions", "comment"]; + 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"])?; // RFC 0002 Designs B-D: read and lowered first, then the persona as it is without them - if ["surprise", "pace", "dispositions"].iter().any(|k| get(kv, k).is_some()) { return build_body(doc); } + if BODY.iter().any(|k| get(kv, k).is_some()) { return build_body(doc); } if get(kv, "senses").is_some() { return build_senses(doc); } // N-R1: a need's reduction pays a goal, so the block needs a drives block need(get(kv, "needs").is_none() || get(kv, "drives").is_some(), "needs", "a needs block needs a drives block (a need's win pays one of its goals)")?; @@ -407,7 +422,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 }; + 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 }; // 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()); } @@ -419,6 +434,8 @@ pub fn build(doc: &Json) -> R { Ok(p) } +/// The blocks `build_body` reads and lowers (RFC 0002 Designs B-D and part 2) +const BODY: [&str; 5] = ["surprise", "pace", "dispositions", "rest", "load"]; /// RFC 0002 Designs B-D, read strictly before the rest of the persona (the rows V-R1, V-R2, P-R1-P-R3, D-R1 and D-R2 refuse at the /// 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 @@ -473,8 +490,47 @@ fn build_body(doc: &Json) -> R { need(!said("patience_spread") || credit || has("drives"), "dispositions.patience_spread", "multiplies learning.credit.half_life_events and drives.pursue.deadline_tau_hours: it needs one of them")?; Some(Disp { optimism: o, patience: pt, volatility: vo }) } }; - // the lowered document: without the three blocks, with pace's two inputs after the declared ones - let mut d2: Vec<(String, Json)> = kv.iter().filter(|(k, _)| !["surprise", "pace", "dispositions"].contains(&k.as_str())).cloned().collect(); + // part 2: rest (W-R1); every field required, no default + let rest_b = match block("rest") { None => None, Some(rj) => { + need(has("learning"), "rest", "a rest block needs a learning block (a rest line scales the learned weights)")?; + let rk = keys(rj, "rest", &["rise_per_hour", "fall_per_hour", "gap_hours", "threshold", "downscale", "prune", "comment"], + &["rise_per_hour", "fall_per_hour", "gap_hours", "threshold", "downscale", "prune"])?; + let f = |k: &str| num(get(rk, k), &at("rest", k), Some(-1e6), Some(1e6), false); + let (up, down, gap, theta, rho, prune) = (f("rise_per_hour")?, f("fall_per_hour")?, f("gap_hours")?, f("threshold")?, f("downscale")?, f("prune")?); + need(up > 0.0, "rest.rise_per_hour", "must be > 0")?; need(down > 0.0, "rest.fall_per_hour", "must be > 0")?; need(gap > 0.0, "rest.gap_hours", "must be > 0")?; + need(theta > 0.0 && theta < 1.0, "rest.threshold", "in (0, 1): the pressure at which an idle gap brings a rest line")?; + need((0.5..1.0).contains(&rho), "rest.downscale", "in [0.5, 1): the factor a rest line scales the learned weights by")?; + need(prune >= 0.0, "rest.prune", "must be >= 0 (a scaled weight below it in size becomes 0)")?; + Some(Rest { up, down, gap, theta, rho, prune }) } }; + // part 2: load (L-R1); `effects` and `hold` optional + let flags: Vec = block("learning").and_then(|l| l.get("from")).and_then(strs).unwrap_or_default(); + let senses: Vec = match block("senses") { Some(Json::Arr(a)) => a.iter().filter_map(|s| s.get("id").and_then(Json::as_str).map(|x| format!("sense_{x}"))).collect(), _ => vec![] }; + let moods: Vec = match block("moods") { Some(Json::Arr(a)) => a.iter().filter_map(|t| t.get("id").and_then(Json::as_str).map(str::to_string)).collect(), _ => vec![] }; + let load = match block("load") { None => None, Some(lj) => { + let lk = keys(lj, "load", &["half_life_hours", "kappa", "theta", "cap", "from", "effects", "hold", "comment"], &["half_life_hours", "kappa", "theta", "cap", "from"])?; + let f = |k: &str| num(get(lk, k), &at("load", k), Some(-1e6), Some(1e6), false); + let (hl, kappa, theta, cap) = (f("half_life_hours")?, f("kappa")?, f("theta")?, f("cap")?); + need(hl > 0.0, "load.half_life_hours", "must be > 0")?; need(kappa > 0.0, "load.kappa", "must be > 0")?; need(cap > 0.0, "load.cap", "must be > 0")?; + need(theta >= 0.0, "load.theta", "must be >= 0 (the evidence below it adds nothing)")?; + let fk = get(lk, "from").and_then(Json::as_obj).filter(|o| !o.is_empty()).ok_or_else(|| perr("load.from", "a mapping evidence -> weight >= 0 (a learning flag, sense_ of a declared sense, expired)"))?; + let mut from = vec![]; + for (k, x) in fk { let fp = at("load.from", k); + need(flags.contains(k) || senses.contains(k) || k == "expired", &fp, "not a learning flag (learning.from), a declared sense (sense_) or expired")?; + from.push((k.clone(), num(Some(x), &fp, Some(0.0), Some(1e6), false)?)); } + let mut effects = vec![]; + if let Some(ek) = get(lk, "effects").filter(|x| !x.is_null()) { let ek = ek.as_obj().ok_or_else(|| perr("load.effects", "a mapping trait or mood -> ordinal shift per unit of L"))?; + for (k, x) in ek { let ep = at("load.effects", k); + need(k != "pursue", &ep, "load never moves pursue")?; need(ids.contains(k), &ep, &format!("unknown trait or mood {k:?}"))?; + effects.push((k.clone(), num(Some(x), &ep, Some(-50.0), Some(50.0), false)?)); } } + let hold = match get(lk, "hold").filter(|x| !x.is_null()) { None => None, Some(hj) => { + let hk = keys(hj, "load.hold", &["mood", "above", "per_hour"], &["mood", "above", "per_hour"])?; + let m = get(hk, "mood").and_then(Json::as_str).filter(|m| moods.iter().any(|x| x == m)).ok_or_else(|| perr("load.hold.mood", "a declared mood"))?.to_string(); + let above = num(get(hk, "above"), "load.hold.above", Some(-1e6), Some(1e6), false)?; + let ph = num(get(hk, "per_hour"), "load.hold.per_hour", Some(0.0), Some(1e6), false)?; + Some((m, above, ph)) } }; + Some(Load { hl, kappa, theta, cap, from, effects, hold }) } }; + // the lowered document: without the blocks, with pace's two inputs and load's one after the declared ones + let mut d2: Vec<(String, Json)> = kv.iter().filter(|(k, _)| !BODY.contains(&k.as_str())).cloned().collect(); if let Some(pc) = &pace { let mut ins: Vec = list(get(&d2, "inputs"), "inputs")?.to_vec(); for (j, x) in ins.iter().enumerate() { if let Some(id) = x.get("id").and_then(Json::as_str) { @@ -487,8 +543,17 @@ fn build_body(doc: &Json) -> R { ins.push(num_in("pace_up", r6(pc.cap - pc.r0), "average reward above r0", eff(1.0))); ins.push(num_in("pace_down", pc.r0, "average reward below r0", eff(-1.0))); match d2.iter_mut().find(|(k, _)| k == "inputs") { Some(x) => x.1 = Json::Arr(ins), None => d2.push(("inputs".into(), Json::Arr(ins))) } } + if let Some(ld) = &load { + let mut ins: Vec = list(get(&d2, "inputs"), "inputs")?.to_vec(); + for (j, x) in ins.iter().enumerate() { if x.get("id").and_then(Json::as_str) == Some(LOAD_ID) { return Err(perr(&format!("inputs[{j}].id"), "reserved for the load block (load_level)")); } } + for (i, h) in list(get(&d2, "habits"), "habits")?.iter().enumerate() { if let Some(w) = h.get("when").and_then(Json::as_obj) { for (k, _) in w { + need(k != LOAD_ID, &format!("habits[{i}].when.{k}"), "reserved for the load block")?; } } } + let mut x = vec![("id".into(), Json::Str(LOAD_ID.into())), ("kind".into(), Json::Str("number".into())), ("max".into(), Json::Num(ld.cap)), ("say".into(), Json::Str("the slow load".into()))]; + 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))?; - p.digest = sha(doc); p.surprise = surprise; p.pace = pace; p.disp = disp; + p.digest = sha(doc); p.surprise = surprise; p.pace = pace; p.disp = disp; p.rest_b = rest_b; p.load = load; Ok(p) } @@ -1187,6 +1252,81 @@ fn host_inputs(d: &Drives, ds: &DState, c: &DriveCtx, raw: &[(String, Json)]) -> /// 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)>)> { + // 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)?; + pressure_step(p, &mut s2, raw)?; + load_step(p, st, &mut s2, raw, &mut host)?; + 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, +/// or with a drives block any goal signal +fn has_feedback(p: &Persona, raw: &[(String, Json)]) -> bool { + p.learning.as_ref().is_some_and(|l| l.from.iter().any(|id| matches!(get(raw, id), Some(Json::Bool(true))))) + || (p.drives.is_some() && get_t(raw, "goals").is_some()) +} +fn stamped_hours(raw: &[(String, Json)]) -> R { num(Some(get(raw, "elapsed_hours").unwrap_or(&Json::Num(0.0))), "inputs.elapsed_hours", Some(0.0), None, false) } +/// Is a rest line due before this event (RFC 0002 part 2)? A rest block, no feedback in the event, and either a line pending or +/// an idle gap (h >= gap_hours) at a pressure S >= threshold +fn rest_due(p: &Persona, st: &State, raw: &[(String, Json)]) -> bool { + let (Some(rb), Some((sp, pend))) = (&p.rest_b, st.pressure) else { return false }; + if has_feedback(p, raw) { return false; } + pend || (stamped_hours(raw).unwrap_or(0.0) >= rb.gap && sp >= rb.theta) +} +/// The rest line's downscale (RFC 0002 part 2): every learned weight Δ ← round(ρ Δ, 6), 0 when |round(ρ Δ, 6)| < prune; the +/// pending flag cleared. Nothing else moves: the credit, the open decisions, the pressure and each learned trait's surprise gate +/// (v, r̂, g and the counts) are left as they are (the gate tracks feedback, and a rest line carries none). +fn rest_apply(p: &Persona, st: &State) -> State { + let Some(rb) = &p.rest_b else { return st.clone() }; + let mut s = st.clone(); + for (_, d) in s.learned.iter_mut() { for x in d.iter_mut() { let y = r6(rb.rho * *x); *x = if y.abs() < rb.prune { 0.0 } else { y }; } } + if let Some((sp, _)) = s.pressure { s.pressure = Some((sp, false)); } + s.seal(p); s +} +/// 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. +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; } + let (rb, ns) = (p.rest_b.as_ref()?, rest_apply(p, st)); + let mut v = vec![("downscale".into(), Json::Num(rb.rho)), ("prune".into(), Json::Num(rb.prune)), ("pressure".into(), Json::Num(st.pressure.map_or(0.0, |x| x.0))), + ("learned".into(), table(&ns.learned))]; + if let Some(gs) = &ns.gate { v.push(("gate".into(), Json::Obj(gs.iter().map(|(t, _)| (t.clone(), Json::Str("unchanged: v, r, g and counts kept".into()))).collect()))); } + Some((Json::Obj(v), ns)) +} +/// The pressure step (RFC 0002 part 2), every event: h < gap_hours: S ← 1 − (1 − S) e^(−rise_per_hour h); h >= gap_hours: when +/// S >= threshold and the event carries feedback, the rest line waits (pending), then S ← S e^(−fall_per_hour h); 6 decimals +fn pressure_step(p: &Persona, s2: &mut State, raw: &[(String, Json)]) -> R<()> { + let (Some(rb), Some((sp, mut pend))) = (&p.rest_b, s2.pressure) else { return Ok(()) }; + let h = stamped_hours(raw)?; + let s = if h < rb.gap { 1.0 - (1.0 - sp) * (-rb.up * h).exp() } else { if sp >= rb.theta && has_feedback(p, raw) { pend = true; } sp * (-rb.down * h).exp() }; + s2.pressure = Some((r6(s.clamp(0.0, 1.0)), pend)); Ok(()) +} +/// The load step (RFC 0002 part 2), right after the pace step: a_t = Σ weight × evidence (a learning flag on: 1; `sense_`: +/// this event's reading, a flag true 1; `expired`: the open decisions that expire at this event, the credit half-life times the +/// patience gene); L ← round(clip(L × 0.5^(h / hl) + κ max(a_t − θ, 0) + κ_h h [the held mood's accumulator at its last level, +/// before this event, > above], 0, cap), 6); load_level = L joins the compiler's inputs +fn load_step(p: &Persona, st: &State, s2: &mut State, raw: &[(String, Json)], host: &mut Vec<(String, Json)>) -> R<()> { + let (Some(ld), Some(l0)) = (&p.load, s2.load_l) else { return Ok(()) }; + let h = stamped_hours(raw)?; + let mut a = 0.0; + for (k, w) in &ld.from { + let e = if k == "expired" { let hl = credit_hl(p, st); st.open.iter().filter(|o| (st.turn.saturating_sub(o.turn + 1) as f64) >= 4.0 * hl).count() as f64 } + else if let Some(sid) = k.strip_prefix("sense_").filter(|_| !learning_flag(p, k)) { + match (&p.senses, &s2.senses) { (Some(sn), Some(ss)) => sn.list.iter().position(|x| x.id == sid).and_then(|i| ss.read[i].as_ref()).map_or(0.0, |r| match &r.raw { Json::Bool(b) => if *b { 1.0 } else { 0.0 }, Json::Num(x) => *x, _ => 0.0 }), _ => 0.0 } } + else if matches!(get(raw, k), Some(Json::Bool(true))) { 1.0 } else { 0.0 }; + a += w * e; } + let held = ld.hold.as_ref().map_or(0.0, |(m, above, ph)| if st.mood.iter().find(|(id, _)| id == m).and_then(|(_, acc)| acc.last()).is_some_and(|x| *x > *above) { ph * h } else { 0.0 }); + let l = r6((l0 * 0.5f64.powf(h / ld.hl) + ld.kappa * (a - ld.theta).max(0.0) + held).clamp(0.0, ld.cap)); + s2.load_l = Some(l); host.push((LOAD_ID.into(), Json::Num(l))); Ok(()) +} +/// L of a state with a load block (RFC 0002 part 2) and its cap; none without one +pub fn load_level(p: &Persona, st: &State) -> Option<(f64, f64)> { p.load.as_ref().zip(st.load_l).map(|(ld, l)| (l, ld.cap)) } +/// 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(); // 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)")?; } } @@ -1383,6 +1523,9 @@ fn stance_body(j: &mut Json, p: &Persona, st: &State) { if let (Some(pc), Some(rb)) = (&p.pace, st.rbar) { if let Some((_, Json::Obj(ins))) = v.iter_mut().find(|(k, _)| k == "inputs") { ins.retain(|(k, _)| !PACE_IDS.contains(&k.as_str())); } v.push(("pace".into(), Json::Obj(vec![("level".into(), Json::Str(pace_level(pc, rb).into())), ("rbar".into(), Json::Num(rb))]))); } + // 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))]))); } 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 @@ -1874,7 +2017,7 @@ 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)> } + gate: Option>, rbar: Option, disp: Vec<(String, f64)>, pressure: Option<(f64, bool)>, load_l: 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)] @@ -1993,6 +2136,9 @@ impl State { if let Some(gs) = &self.gate { v.push(("surprise".into(), Json::Obj(gs.iter().map(|(t, g)| (t.clone(), g.to_json())).collect()))); } if let Some(r) = self.rbar { v.push(("pace".into(), Json::Obj(vec![("rbar".into(), Json::Num(r))]))); } if !self.disp.is_empty() { v.push(("dispositions".into(), disp_json(&self.disp))); } + // RFC 0002 part 2, only with their blocks: the rest pressure and its pending line, the load + if let Some((sp, pend)) = self.pressure { v.push(("pressure".into(), Json::Obj(vec![("s".into(), Json::Num(sp)), ("pending".into(), Json::Bool(pend))]))); } + if let Some(l) = self.load_l { v.push(("load".into(), Json::Obj(vec![("l".into(), Json::Num(l))]))); } Json::Obj(v) } pub fn to_json(&self, p: &Persona) -> Json { let mut b = self.body(p); if let Json::Obj(v) = &mut b { v.push(("digest".into(), Json::Str(self.digest.clone()))); } b } @@ -2018,8 +2164,8 @@ impl State { need(p.senses.is_none() || get(kv, "senses").is_some(), "state.senses", "required with a senses block")?; let keeps_from = learned_from0(p).is_some(); need(!keeps_from || get(kv, "learned_from").is_some(), "state.learned_from", "required with a learning block and line.why_past")?; - const SK: [&str; 22] = ["probbit_persona_state", "persona", "seed", "turn", "genes", "mood", "history", "prev", "rest", "digest", "learned", "credit", "drives", "senses", "learned_from", - "open_credit", "prev_stance", "control_closed", "needs", "surprise", "pace", "dispositions"]; + const SK: [&str; 24] = ["probbit_persona_state", "persona", "seed", "turn", "genes", "mood", "history", "prev", "rest", "digest", "learned", "credit", "drives", "senses", "learned_from", + "open_credit", "prev_stance", "control_closed", "needs", "surprise", "pace", "dispositions", "pressure", "load"]; let mut sk: Vec<&str> = SK[..10].to_vec(); if p.learning.is_some() { sk.extend(&SK[10..12]); } if p.drives.is_some() { sk.push(SK[12]); } @@ -2032,6 +2178,9 @@ impl State { if p.pace.is_some() { sk.push(SK[20]); need(get(kv, "pace").is_some(), "state.pace", "required with a pace block")?; } if p.disp.is_some_and(|d| d.optimism > 0.0 || d.patience > 0.0 || d.volatility > 0.0) { sk.push(SK[21]); need(get(kv, "dispositions").is_some(), "state.dispositions", "required with a disposition spread above 0")?; } + // RFC 0002 part 2: the pressure with a rest block, the load with a load block + if p.rest_b.is_some() { sk.push(SK[22]); need(get(kv, "pressure").is_some(), "state.pressure", "required with a rest block")?; } + if p.load.is_some() { sk.push(SK[23]); need(get(kv, "load").is_some(), "state.load", "required with a load block")?; } keys(j, "state", &sk, &["persona", "seed", "turn", "genes", "mood", "history", "prev", "digest"]) .map_err(|e| if e.msg.starts_with("unknown field") && (e.path == "state.learned" || e.path == "state.credit") { perr(&e.path, "this persona has no learning block") } else if e.msg.starts_with("unknown field") && e.path == "state.drives" { perr(&e.path, "this persona has no drives block") } @@ -2041,6 +2190,8 @@ impl State { else if e.msg.starts_with("unknown field") && e.path == "state.needs" { perr(&e.path, "this persona has no needs block") } else if e.msg.starts_with("unknown field") && e.path == "state.surprise" { perr(&e.path, "this persona has no surprise block") } else if e.msg.starts_with("unknown field") && e.path == "state.pace" { perr(&e.path, "this persona has no pace block") } + else if e.msg.starts_with("unknown field") && e.path == "state.pressure" { perr(&e.path, "this persona has no rest block") } + else if e.msg.starts_with("unknown field") && e.path == "state.load" { perr(&e.path, "this persona has no load block") } else if e.msg.starts_with("unknown field") && e.path == "state.dispositions" { perr(&e.path, "this persona stores no disposition genes (no spread above 0)") } else { e })?; let whole = |k: &str| num(get(kv, k), &at("state", k), Some(0.0), Some(9_007_199_254_740_992.0), true).map(|x| x as u64); let (seed, turn) = (whole("seed")?, whole("turn")?); @@ -2079,6 +2230,12 @@ impl State { let gate = match (&p.surprise, &p.learning) { (Some(sp), Some(l)) => Some(read_gate(p, sp, l, get(kv, "surprise").unwrap())?), _ => None }; let rbar = match &p.pace { None => None, Some(pc) => { let rk = keys(get(kv, "pace").unwrap(), "state.pace", &["rbar"], &["rbar"])?; Some(get(rk, "rbar").and_then(Json::as_f64).filter(|x| *x >= 0.0 && *x <= pc.cap + 5e-7 && r6(*x) == *x).ok_or_else(|| perr("state.pace.rbar", format!("the average reward in [0, {}] (cap), 6 decimals", pyn(pc.cap))))?) } }; + let pressure = match &p.rest_b { None => None, Some(_) => { let pk = keys(get(kv, "pressure").unwrap(), "state.pressure", &["s", "pending"], &["s", "pending"])?; + let sp = get(pk, "s").and_then(Json::as_f64).filter(|x| *x >= 0.0 && *x <= 1.0 && r6(*x) == *x).ok_or_else(|| perr("state.pressure.s", "the rest pressure in [0, 1], 6 decimals"))?; + let pend = match get(pk, "pending") { Some(Json::Bool(b)) => *b, _ => return Err(perr("state.pressure.pending", "true | false (a rest line waits for an event without feedback)")) }; + Some((sp, pend)) } }; + let load_l = match &p.load { None => None, Some(ld) => { let lk = keys(get(kv, "load").unwrap(), "state.load", &["l"], &["l"])?; + Some(get(lk, "l").and_then(Json::as_f64).filter(|x| *x >= 0.0 && *x <= ld.cap + 5e-7 && r6(*x) == *x).ok_or_else(|| perr("state.load.l", format!("the load in [0, {}] (cap), 6 decimals", pyn(ld.cap))))?) } }; let disp = disp_genes(p, seed); if !disp.is_empty() { need(canon(get(kv, "dispositions").unwrap()) == canon(&disp_json(&disp)), "state.dispositions", "disposition genes do not match the seed")?; } // learned_from: per learned trait {rewards, corrections}, whole numbers >= 0 @@ -2106,7 +2263,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, + 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, digest: get(kv, "digest").and_then(Json::as_str).unwrap_or("").to_string() }) } } @@ -2374,7 +2531,7 @@ fn why(p: &Persona, st: &State, meta: &Meta, bound: &[String], status: &str, sta for id in ids { if !meta.active_inputs.contains(id) { continue; } if p.senses.as_ref().is_some_and(|sn| sn.synthetic.contains(id)) { continue; } if p.has_needs() && id.starts_with("need_") && p.drives.as_ref().is_some_and(|d| d.synthetic.contains(id)) { continue; } // the need clause says it - if p.is_pace(id) { continue; } // the pace clause says it + if p.is_owned(id) { continue; } // the pace clause says it; load_level is the engine's let (eff, scale, say): (&[(String, Vec)], f64, String) = if let Some(x) = p.input(id) { let v = get(&meta.vals, id).unwrap(); let eff = if x.kind == Kind::Level { x.by_level.iter().find(|(l, _)| Some(l.as_str()) == v.as_str()).map_or(&[][..], |(_, e)| &e[..]) } else { &x.effects[..] }; @@ -2515,6 +2672,8 @@ fn turn_src(p: &Persona, st: &State, raw: &Json, no_inertia: bool, eng: Engine, let (pay, raw) = take_pay(p, raw)?; // 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) }; // RFC 0002 Design B: the surprise gate steps v, r̂, g and the counts of each paid learned trait before the learner reads g @@ -2585,7 +2744,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) }; + 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) }; 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) { @@ -2922,7 +3081,7 @@ pub fn describe(p: &Persona) -> Json { d } /// A trait or input the drives block adds when it is lowered (`pursue`, the drive aggregates and goal conditions): not the author's -fn lowered(p: &Persona, id: &str) -> bool { p.drives.as_ref().is_some_and(|d| id == "pursue" || d.synthetic.iter().any(|x| x == id)) || sense_input(p, id) || p.is_pace(id) } +fn lowered(p: &Persona, id: &str) -> bool { p.drives.as_ref().is_some_and(|d| id == "pursue" || d.synthetic.iter().any(|x| x == id)) || sense_input(p, id) || p.is_owned(id) } /// An input the senses block adds when it is lowered (`sense__up/down`, the condition flags): not the host's fn sense_input(p: &Persona, id: &str) -> bool { p.senses.as_ref().is_some_and(|s| s.synthetic.iter().any(|x| x == id)) } /// `check`: valid, with its digest and sizes (one engine call: the resting stance) @@ -3086,7 +3245,7 @@ pub fn pressure(p: &Persona, pr: &Prop, before: &State, doc: &Json) -> f64 { /// The event alphabet: per declared input its non-default values (a flag: true; a level input: its other levels; a number: `grid` /// x its max, without its default), then `elapsed_hours` (`hours`) when a mood has a half-life. -> (id, kind, values) pub fn alphabet(p: &Persona, grid: &[f64], hours: &[f64]) -> Vec<(String, &'static str, Vec)> { - let host = |x: &&Input| !p.drives.as_ref().is_some_and(|d| d.synthetic.contains(&x.id)) && !sense_input(p, &x.id) && !p.is_pace(&x.id); // the drives' and senses' own inputs are not the host's + let host = |x: &&Input| !p.drives.as_ref().is_some_and(|d| d.synthetic.contains(&x.id)) && !sense_input(p, &x.id) && !p.is_owned(&x.id); // the drives' and senses' own inputs are not the host's let mut out: Vec<(String, &'static str, Vec)> = p.inputs.iter().filter(host).map(|x| match x.kind { Kind::Flag => (x.id.clone(), "flag", vec![Json::Bool(true)]), Kind::Level => (x.id.clone(), "level", x.levels.iter().filter(|l| Some(l.as_str()) != x.default.as_str()).map(|l| Json::Str(l.clone())).collect()), @@ -3135,7 +3294,7 @@ pub fn thresholds(p: &Persona, props: &[Prop]) -> Vec { let mut out: Vec = vec![]; for w in p.habits.iter().map(|h| &h.when).chain(props.iter().map(|q| &q.habit.when)) { for (k, c) in w { // the host's number inputs (the drives' own inputs are not on the search's alphabet) - let Some(x) = p.input(k).filter(|x| x.kind == Kind::Number && !p.drives.as_ref().is_some_and(|d| d.synthetic.contains(&x.id)) && !sense_input(p, &x.id) && !p.is_pace(&x.id)) else { continue }; + let Some(x) = p.input(k).filter(|x| x.kind == Kind::Number && !p.drives.as_ref().is_some_and(|d| d.synthetic.contains(&x.id)) && !sense_input(p, &x.id) && !p.is_owned(&x.id)) else { continue }; let ts: Vec = match c { Json::Num(t) => vec![*t], _ => ["at_least", "at_most"].iter().filter_map(|b| c.get(b).and_then(Json::as_f64)).collect() }; for t in ts { if t >= 0.0 && t <= x.max { let g = t / x.max; if !out.contains(&g) { out.push(g); } } } } } out.sort_by(|a, b| a.partial_cmp(b).unwrap()); out @@ -4814,4 +4973,87 @@ mod tests { if !bad { let (d, ns) = ok_turn(&p, &b, &ev); db.push(canon(&d)); b = ns; } } assert!(refused > 0, "seed {seed}"); assert_eq!(da, db, "seed {seed}"); assert_eq!(canon(&a.to_json(&p)), canon(&b.to_json(&p)), "seed {seed}"); } } + + /// RFC 0002 part 2 on the engine: the refusals W-R1 and L-R1 at the field's path; load_level reserved with the block and + /// refused when the host sends it; a persona without the blocks keeps its documents (no pressure, no load in the state) + #[test] + fn rest_and_load_are_refused_at_their_paths() { + const REST: &str = r#""rest":{"rise_per_hour":0.055,"fall_per_hour":0.238,"gap_hours":4,"threshold":0.5,"downscale":0.5,"prune":0.05}"#; + let rest = |from: &str, to: &str| err(body_with(&GATE.replacen(r#""surprise":"#, &format!("{REST},\"surprise\":"), 1), from, to)); + let no_learning = GATE.replace(r#""learning":{"from":["praise","criticism"],"traits":["approach"],"rate":0.5,"step_cap":0.2,"total_cap":1,"credit":{"max_open":8,"half_life_events":32}},"#, "") + .replace(r#","surprise":{"volatility_rate":0.3,"v0":0.45,"r_ref":4,"r_max":20,"g_max":3}"#, &format!(",{REST}")); + assert!(!no_learning.contains(r#""learning""#) && no_learning.contains(r#""rest""#)); + assert_eq!(err(build(&json::parse(&no_learning).unwrap())), "rest: a rest block needs a learning block (a rest line scales the learned weights)"); + assert_eq!(rest(r#""downscale":0.5"#, r#""downscale":1"#), "rest.downscale: in [0.5, 1): the factor a rest line scales the learned weights by"); + assert_eq!(rest(r#""downscale":0.5"#, r#""downscale":0.4"#), "rest.downscale: in [0.5, 1): the factor a rest line scales the learned weights by"); + assert_eq!(rest(r#""prune":0.05"#, r#""prune":-0.1"#), "rest.prune: must be >= 0 (a scaled weight below it in size becomes 0)"); + assert_eq!(rest(r#""threshold":0.5"#, r#""threshold":1"#), "rest.threshold: in (0, 1): the pressure at which an idle gap brings a rest line"); + assert_eq!(rest(r#""gap_hours":4"#, r#""gap_hours":0"#), "rest.gap_hours: must be > 0"); + assert_eq!(rest(r#""rise_per_hour":0.055"#, r#""rise_per_hour":0"#), "rest.rise_per_hour: must be > 0"); + assert_eq!(rest(r#","prune":0.05"#, ""), "rest.prune: required"); + const LOAD: &str = r#""load":{"half_life_hours":72,"kappa":0.5,"theta":0.5,"cap":4,"from":{"criticism":1},"effects":{"approach":1}}"#; + let lp = GATE.replacen(r#""surprise":"#, &format!("{LOAD},\"surprise\":"), 1); + let load = |from: &str, to: &str| err(body_with(&lp, from, to)); + assert_eq!(load(r#""cap":4"#, r#""cap":0"#), "load.cap: must be > 0"); + assert_eq!(load(r#""kappa":0.5"#, r#""kappa":0"#), "load.kappa: must be > 0"); + assert_eq!(load(r#""half_life_hours":72"#, r#""half_life_hours":0"#), "load.half_life_hours: must be > 0"); + assert_eq!(load(r#""from":{"criticism":1}"#, r#""from":{"sense_fail":1}"#), "load.from.sense_fail: not a learning flag (learning.from), a declared sense (sense_) or expired"); + assert_eq!(load(r#""from":{"criticism":1}"#, r#""from":{"criticism":-1}"#), "load.from.criticism: must be >= 0"); + assert_eq!(load(r#""effects":{"approach":1}"#, r#""effects":{"approach":1},"hold":{"mood":"strain","above":1,"per_hour":0.01}"#), "load.hold.mood: a declared mood"); + assert_eq!(load(r#"{"id":"praise","kind":"flag"}"#, r#"{"id":"load_level","kind":"number"},{"id":"praise","kind":"flag"}"#), "inputs[0].id: reserved for the load block (load_level)"); + let p = build(&json::parse(&lp).unwrap()).unwrap(); let st = init(&p, Some(0), false, &run); + assert_eq!(tn(&p, &st, r#"{"load_level":1,"src":"env:host"}"#).err().unwrap(), "inputs.load_level: reserved for the load block (the engine sets it from the load)"); + assert!(body_with(&lp, r#""from":{"criticism":1}"#, r#""from":{"criticism":1,"expired":0.5}"#).is_ok()); + // without the blocks: no pressure, no load in the state (the documents of before) + let q = build(&json::parse(GATE).unwrap()).unwrap(); let sj = init(&q, Some(0), false, &run).to_json(&q); + assert!(sj.get("pressure").is_none() && sj.get("load").is_none()); + } + + /// RFC 0002 part 2, rest: the pressure rises over active hours (1 − (1 − S) e^(−0.055 h)); an idle gap at S >= 0.5 with + /// feedback defers the line (pending); the next event without feedback scales every learned weight to round(0.5 Δ, 6), + /// pruned under 0.05, keeps the surprise gate (v, r̂, g, counts) and the credit as they were and clears pending + #[test] + fn a_rest_line_scales_the_learned_weights_and_keeps_the_gate() { + let doc = GATE.replacen(r#""surprise":"#, r#""rest":{"rise_per_hour":0.055,"fall_per_hour":0.238,"gap_hours":4,"threshold":0.5,"downscale":0.5,"prune":0.05},"surprise":"#, 1); + let p = build(&json::parse(&doc).unwrap()).unwrap(); let mut st = init(&p, Some(0), false, &run); + assert_eq!(st.to_json(&p).get("pressure").map(canon), Some(r#"{"pending":false,"s":0}"#.to_string())); + let mut s_want = 0.0; + for t in 0..20 { let ev = if t == 0 { r#"{"elapsed_hours":0,"src":"human:principal"}"#.to_string() } else { r#"{"elapsed_hours":1,"praise":true,"src":"human:principal"}"#.to_string() }; + st = tn(&p, &st, &ev).unwrap().1; if t > 0 { s_want = r6(1.0 - (1.0 - s_want) * (-0.055f64).exp()); } + assert_eq!(st.pressure, Some((s_want, false)), "event {t}"); } + assert!(s_want >= 0.5); + // the gap with feedback: no rest line, pending + let before = st.clone(); assert!(!rest_due(&p, &st, &[("elapsed_hours".into(), Json::Num(8.0)), ("praise".into(), Json::Bool(true))])); + st = tn(&p, &st, r#"{"elapsed_hours":8,"praise":true,"src":"human:principal"}"#).unwrap().1; + let s8 = r6(s_want * (-0.238f64 * 8.0).exp()); assert_eq!(st.pressure, Some((s8, true))); + assert_ne!(before.learned, zeros(&p)); + // the next event without feedback: the rest line, whatever its gap + let ev = json::parse(r#"{"elapsed_hours":0,"src":"human:principal"}"#).unwrap(); + let (line, rested) = rest_line(&p, &st, &ev).expect("a rest line is due"); + for ((_, a), (_, b)) in st.learned.iter().zip(&rested.learned) { for (x, y) in a.iter().zip(b) { let w = r6(0.5 * x); assert_eq!(*y, if w.abs() < 0.05 { 0.0 } else { w }); } } + assert_eq!(rested.gate, st.gate); assert_eq!(rested.credit, st.credit); assert_eq!(rested.pressure, Some((s8, false))); + assert_eq!(line.get("gate").map(canon), Some(r#"{"approach":"unchanged: v, r, g and counts kept"}"#.to_string())); + let (doc2, after) = tn(&p, &st, r#"{"elapsed_hours":0,"src":"human:principal"}"#).unwrap(); + assert_eq!(after.learned, rested.learned); assert_eq!(after.gate, st.gate); assert_eq!(after.pressure, Some((s8, false))); + assert!(doc2.get("stance").is_some()); + // no feedback, no gap, nothing pending: no line + assert!(rest_line(&p, &after, &ev).is_none()); + } + + /// RFC 0002 part 2, load: 4 criticisms at 0 h give L = 4 × 0.5 × (1 − 0.5) = 1 (l2's induction), a quiet 72 h halves it; the + /// stance says `load: {l}` and leaves load_level out of `inputs`; with an effect on a trait the number input moves the field + #[test] + fn the_load_follows_its_closed_form_and_enters_as_a_number_input() { + let doc = GATE.replacen(r#""surprise":"#, r#""load":{"half_life_hours":72,"kappa":0.5,"theta":0.5,"cap":4,"from":{"criticism":1},"effects":{"approach":1}},"surprise":"#, 1); + let p = build(&json::parse(&doc).unwrap()).unwrap(); let mut st = init(&p, Some(0), false, &run); + let mut d = Json::Null; + for _ in 0..4 { let r = tn(&p, &st, r#"{"elapsed_hours":0,"criticism":true,"src":"human:principal"}"#).unwrap(); d = r.0; st = r.1; } + assert_eq!(st.load_l, Some(1.0)); assert_eq!(gnum(&d, &["load", "l"]), 1.0); + assert!(d.get("inputs").and_then(|i| i.get("load_level")).is_none(), "{}", canon(&d)); + st = tn(&p, &st, r#"{"elapsed_hours":72,"src":"env:host"}"#).unwrap().1; assert_eq!(st.load_l, Some(0.5)); + let bad = { let mut j = st.to_json(&p); if let Json::Obj(v) = &mut j { for (k, x) in v.iter_mut() { if k == "load" { *x = json::parse(r#"{"l":4.5}"#).unwrap(); } } } j }; + let body = Json::Obj(bad.as_obj().unwrap().iter().filter(|(k, _)| k != "digest").cloned().collect()); + let mut bad = bad; if let Json::Obj(v) = &mut bad { for (k, x) in v.iter_mut() { if k == "digest" { *x = Json::Str(sha(&body)); } } } + assert_eq!(err(State::read(&p, &bad)), "state.load.l: the load in [0, 4] (cap), 6 decimals"); + } } diff --git a/probbit-cli/tests/fixtures/strands/next-load.strand b/probbit-cli/tests/fixtures/strands/next-block.strand similarity index 81% rename from probbit-cli/tests/fixtures/strands/next-load.strand rename to probbit-cli/tests/fixtures/strands/next-block.strand index d93d5ab..2e71493 100644 --- a/probbit-cli/tests/fixtures/strands/next-load.strand +++ b/probbit-cli/tests/fixtures/strands/next-block.strand @@ -1,7 +1,7 @@ -{"probbit_strand":1,"engine":"probbit 0.11.0-dev","persona":{"name":"Retry policy sensed","version":"1","digest":"sha256:b654b85390369199dcbcfc50b37eefa6c6b9b83e79ff7baa8b9a33bd4fb0d64b"},"seed":0,"state":{"control_closed":[],"credit":{"action":[0,0]},"digest":"sha256:67a5cc79eca9d7b647276f895877c90e404dd2651074ad8433c9b6652c70f633","genes":{"react":{},"shift":{}},"history":{},"learned":{"action":[0,0]},"learned_from":{"action":{"corrections":0,"rewards":0}},"mood":{},"open_credit":[],"persona":{"digest":"sha256:7025afeaa36b57a02b12333a905f6c378065b398b0bce51999bc5ac513112da6","name":"Retry policy sensed","version":"1"},"prev":{"action":"retry"},"prev_stance":null,"probbit_persona_state":1,"rest":{"action":"retry"},"seed":0,"senses":{"genes":{"gain":{},"habituation":{},"threshold":{}},"norm":{"tool_fail":{"mean":0.05,"n":0,"var":0.05}}},"turn":0},"document":{"probbit_persona":1,"identity":{"name":"Retry policy sensed","version":"1","seed":0},"traits":[{"id":"action","levels":["retry","ask"],"logw":[0.5,0],"say":["retry the local probe","escalate to the operator"]}],"inputs":[{"id":"error","kind":"flag"},{"id":"praise","kind":"flag"}],"senses":[{"id":"tool_fail","kind":"flag","unit":"event","src":["env"],"prior":{"mean":0.05,"var":0.05},"drift_cap":0.25,"say":"tool failures","effects":{"action":0.8}}],"learning":{"from":["praise"],"traits":["action"],"rate":0.3,"step_cap":0.05,"total_cap":0.4,"credit":{"max_open":8,"half_life_events":32}},"reward_from":["env"],"load":{"half_life_hours":72,"kappa":0.5,"theta":1,"cap":4,"from":{"criticism":1,"sense_tool_fail":1},"effects":{"caution":0.5}}}} -{"inputs":{"await":true,"elapsed_hours":0.5,"senses":{"tool_fail":false},"src":"env:runner"},"n":1,"prev":"sha256:5d85b22fa68accfc1e20c6e9ade18a2bffd1733ff3dd01a1bc5d2bded96fb5d9","stance":"sha256:450185df61d7a3a68d4e7f861b2352f622adaca83f8f21713ecd8e4eb72a23eb","state":"sha256:997172412f1223235c061630a8efbebe0b505757ccadaa56db31c179fc9fc33b"} -{"inputs":{"await":true,"elapsed_hours":0.25,"error":true,"senses":{"tool_fail":true},"src":"env:runner"},"n":2,"prev":"sha256:ff868071204e6f8ca476caa7cdc0b4be3a8806e8ddaf26097fe2dd87324e47d0","stance":"sha256:80c76894a340684334b62a1c34760a249da3ff501ec398825afb5caaf5e5e8ee","state":"sha256:18ca05fee3201af041ef45363f66576836c373a18fcd4b11db572007d21a8093"} -{"inputs":{"elapsed_hours":0.1,"for":0,"praise":true,"src":"env:ci"},"n":3,"prev":"sha256:cb553c8b116051c78f0257cdd7cba7543934042c12385d74b5ea3d2d4831c068","stance":"sha256:bdb166c13e60b810f0352390657823bb816a5956e609da463e9138642995ca26","state":"sha256:5f720df0b4edf33586a73de4eeda3bf7ca8eb45d60b1b809ef0aecda75e90526"} -{"inputs":{"await":true,"elapsed_hours":1,"senses":{"tool_fail":false},"src":"env:runner"},"n":4,"prev":"sha256:4e9eb5abdb73579491fad6baa486638a1bcbc3f2b46c5113d86159c1b851d61d","stance":"sha256:a7278b41acb3fe12967130c9c8bc814cbaff3c0fa62be2f8bb483340505d0207","state":"sha256:e06cfc6167136c9063a2a6dbf8fe0e31a736c2ccdb7f544fadf3aabe482b8bec"} -{"checkpoint":4,"prev":"sha256:ef2dce2f14f09087cb0f5a7634af904aec63cf97931b05007209a5137f470009","stance":{"credit":{"closed":[],"open":[1,3],"paid":null,"stance":"sha256:c8489fca40c7c1be178879b97fe18d5a0d7b2c0112b9ce4b3d6e80f54bbaceee"},"engine":{"program":"sha256:88b1c7a3a203ed88d93f9ed2c293a03c6eab6e1250d67547908de00aea2ce80b","tier":"enumerate","verdict":"exact"},"escalate":null,"habits":{"active":[],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"await":true,"error":false,"praise":false,"senses":{"tool_fail":false},"src":"env:runner"},"line":"Stance: retry the local probe.","line_tokens":8,"mood":{},"noticed":null,"persona":{"digest":"sha256:7025afeaa36b57a02b12333a905f6c378065b398b0bce51999bc5ac513112da6","name":"Retry policy sensed","version":"1"},"probbit_persona_turn":1,"seed":0,"senses":{"tool_fail":{"mean":0.110495,"n":2,"raw":false,"src":"env:runner","z":-0.332458,"zd":0}},"stance":{"action":{"level":"retry","odds":{"ask":0.354834,"retry":0.645166},"p":0.645166,"released":true}},"state_digest":"sha256:e06cfc6167136c9063a2a6dbf8fe0e31a736c2ccdb7f544fadf3aabe482b8bec","status":"ok","turn":3,"unsure":[],"why":"baseline (no live evidence)"},"state":{"control_closed":[],"credit":{"action":[0.354834,-0.354834]},"digest":"sha256:e06cfc6167136c9063a2a6dbf8fe0e31a736c2ccdb7f544fadf3aabe482b8bec","genes":{"react":{},"shift":{}},"history":{},"learned":{"action":[0.048929,-0.048929]},"learned_from":{"action":{"corrections":0,"rewards":1}},"mood":{},"open_credit":[{"credit":{"action":[-0.006061,0.006061]},"stance":"sha256:3a09c0ac90216571ad6197cd3d6ae9f82f0daa3be90d729a4df9e98e96bee783","turn":1},{"credit":{"action":[0.354834,-0.354834]},"stance":"sha256:c8489fca40c7c1be178879b97fe18d5a0d7b2c0112b9ce4b3d6e80f54bbaceee","turn":3}],"persona":{"digest":"sha256:7025afeaa36b57a02b12333a905f6c378065b398b0bce51999bc5ac513112da6","name":"Retry policy sensed","version":"1"},"prev":{"action":"retry"},"prev_stance":"sha256:c8489fca40c7c1be178879b97fe18d5a0d7b2c0112b9ce4b3d6e80f54bbaceee","probbit_persona_state":1,"rest":{"action":"retry"},"seed":0,"senses":{"genes":{"gain":{},"habituation":{},"threshold":{}},"norm":{"tool_fail":{"mean":0.103095,"n":3,"var":0.094497}}},"turn":4}} -{"inputs":{"elapsed_hours":0.2,"for":3,"praise":true,"src":"env:ci"},"n":5,"prev":"sha256:8c695b505711a5eb4c616dbdc8bdfa519d5e5cc7d12ca8b2221613e87964b19a","stance":"sha256:2d29f2748955ef31b703bcdcac6a9235bf218f3335f917291d7d933c856be285","state":"sha256:bac04f1bad4017a683816ac80af1e6bc21a15968c2d7988c631d4efe8cde46c7"} +{"probbit_strand":1,"engine":"probbit 0.11.0-dev","persona":{"name":"Retry policy sensed","version":"1","digest":"sha256:295e9ab7398a2d05e8a6465d1ae97cb17c1972c928d93029866c1233f486c528"},"seed":0,"state":{"control_closed":[],"credit":{"action":[0,0]},"digest":"sha256:67a5cc79eca9d7b647276f895877c90e404dd2651074ad8433c9b6652c70f633","genes":{"react":{},"shift":{}},"history":{},"learned":{"action":[0,0]},"learned_from":{"action":{"corrections":0,"rewards":0}},"mood":{},"open_credit":[],"persona":{"digest":"sha256:7025afeaa36b57a02b12333a905f6c378065b398b0bce51999bc5ac513112da6","name":"Retry policy sensed","version":"1"},"prev":{"action":"retry"},"prev_stance":null,"probbit_persona_state":1,"rest":{"action":"retry"},"seed":0,"senses":{"genes":{"gain":{},"habituation":{},"threshold":{}},"norm":{"tool_fail":{"mean":0.05,"n":0,"var":0.05}}},"turn":0},"document":{"probbit_persona":1,"identity":{"name":"Retry policy sensed","version":"1","seed":0},"traits":[{"id":"action","levels":["retry","ask"],"logw":[0.5,0],"say":["retry the local probe","escalate to the operator"]}],"inputs":[{"id":"error","kind":"flag"},{"id":"praise","kind":"flag"}],"senses":[{"id":"tool_fail","kind":"flag","unit":"event","src":["env"],"prior":{"mean":0.05,"var":0.05},"drift_cap":0.25,"say":"tool failures","effects":{"action":0.8}}],"learning":{"from":["praise"],"traits":["action"],"rate":0.3,"step_cap":0.05,"total_cap":0.4,"credit":{"max_open":8,"half_life_events":32}},"reward_from":["env"],"sleep":{"half_life_hours":72,"kappa":0.5,"theta":1,"cap":4,"from":{"criticism":1,"sense_tool_fail":1},"effects":{"caution":0.5}}}} +{"inputs":{"await":true,"elapsed_hours":0.5,"senses":{"tool_fail":false},"src":"env:runner"},"n":1,"prev":"sha256:4f2fee851df3e3092a6cdc60c4408de3b8de87c6f206b02276acfc16a1bc3597","stance":"sha256:450185df61d7a3a68d4e7f861b2352f622adaca83f8f21713ecd8e4eb72a23eb","state":"sha256:997172412f1223235c061630a8efbebe0b505757ccadaa56db31c179fc9fc33b"} +{"inputs":{"await":true,"elapsed_hours":0.25,"error":true,"senses":{"tool_fail":true},"src":"env:runner"},"n":2,"prev":"sha256:1c8e2f74fe092726cec5a4e890dd5d64068f8bf66eee06fd86126b29ba428b4e","stance":"sha256:80c76894a340684334b62a1c34760a249da3ff501ec398825afb5caaf5e5e8ee","state":"sha256:18ca05fee3201af041ef45363f66576836c373a18fcd4b11db572007d21a8093"} +{"inputs":{"elapsed_hours":0.1,"for":0,"praise":true,"src":"env:ci"},"n":3,"prev":"sha256:6590c01ff86bbd48b16c3f2294224d61240a141d41a7765ff5a740a029d1b372","stance":"sha256:bdb166c13e60b810f0352390657823bb816a5956e609da463e9138642995ca26","state":"sha256:5f720df0b4edf33586a73de4eeda3bf7ca8eb45d60b1b809ef0aecda75e90526"} +{"inputs":{"await":true,"elapsed_hours":1,"senses":{"tool_fail":false},"src":"env:runner"},"n":4,"prev":"sha256:bbfa7ffd4b164f47027f869d13d736a19873a430139cc013342acec88f9e72a2","stance":"sha256:a7278b41acb3fe12967130c9c8bc814cbaff3c0fa62be2f8bb483340505d0207","state":"sha256:e06cfc6167136c9063a2a6dbf8fe0e31a736c2ccdb7f544fadf3aabe482b8bec"} +{"checkpoint":4,"prev":"sha256:c542ab58d10b6a3ed0fc8231705bb58059479b6c8541c689e98b7ae29ac6e593","stance":{"credit":{"closed":[],"open":[1,3],"paid":null,"stance":"sha256:c8489fca40c7c1be178879b97fe18d5a0d7b2c0112b9ce4b3d6e80f54bbaceee"},"engine":{"program":"sha256:88b1c7a3a203ed88d93f9ed2c293a03c6eab6e1250d67547908de00aea2ce80b","tier":"enumerate","verdict":"exact"},"escalate":null,"habits":{"active":[],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"await":true,"error":false,"praise":false,"senses":{"tool_fail":false},"src":"env:runner"},"line":"Stance: retry the local probe.","line_tokens":8,"mood":{},"noticed":null,"persona":{"digest":"sha256:7025afeaa36b57a02b12333a905f6c378065b398b0bce51999bc5ac513112da6","name":"Retry policy sensed","version":"1"},"probbit_persona_turn":1,"seed":0,"senses":{"tool_fail":{"mean":0.110495,"n":2,"raw":false,"src":"env:runner","z":-0.332458,"zd":0}},"stance":{"action":{"level":"retry","odds":{"ask":0.354834,"retry":0.645166},"p":0.645166,"released":true}},"state_digest":"sha256:e06cfc6167136c9063a2a6dbf8fe0e31a736c2ccdb7f544fadf3aabe482b8bec","status":"ok","turn":3,"unsure":[],"why":"baseline (no live evidence)"},"state":{"control_closed":[],"credit":{"action":[0.354834,-0.354834]},"digest":"sha256:e06cfc6167136c9063a2a6dbf8fe0e31a736c2ccdb7f544fadf3aabe482b8bec","genes":{"react":{},"shift":{}},"history":{},"learned":{"action":[0.048929,-0.048929]},"learned_from":{"action":{"corrections":0,"rewards":1}},"mood":{},"open_credit":[{"credit":{"action":[-0.006061,0.006061]},"stance":"sha256:3a09c0ac90216571ad6197cd3d6ae9f82f0daa3be90d729a4df9e98e96bee783","turn":1},{"credit":{"action":[0.354834,-0.354834]},"stance":"sha256:c8489fca40c7c1be178879b97fe18d5a0d7b2c0112b9ce4b3d6e80f54bbaceee","turn":3}],"persona":{"digest":"sha256:7025afeaa36b57a02b12333a905f6c378065b398b0bce51999bc5ac513112da6","name":"Retry policy sensed","version":"1"},"prev":{"action":"retry"},"prev_stance":"sha256:c8489fca40c7c1be178879b97fe18d5a0d7b2c0112b9ce4b3d6e80f54bbaceee","probbit_persona_state":1,"rest":{"action":"retry"},"seed":0,"senses":{"genes":{"gain":{},"habituation":{},"threshold":{}},"norm":{"tool_fail":{"mean":0.103095,"n":3,"var":0.094497}}},"turn":4}} +{"inputs":{"elapsed_hours":0.2,"for":3,"praise":true,"src":"env:ci"},"n":5,"prev":"sha256:84e11aa2dcf3e1d3d153a091c0d39bbb55db4096ea9864d4d81a254e4c6c4a1d","stance":"sha256:2d29f2748955ef31b703bcdcac6a9235bf218f3335f917291d7d933c856be285","state":"sha256:bac04f1bad4017a683816ac80af1e6bc21a15968c2d7988c631d4efe8cde46c7"} diff --git a/probbit-cli/tests/fixtures/strands/next-rest.strand b/probbit-cli/tests/fixtures/strands/next-line.strand similarity index 98% rename from probbit-cli/tests/fixtures/strands/next-rest.strand rename to probbit-cli/tests/fixtures/strands/next-line.strand index 13fa2c6..57ec486 100644 --- a/probbit-cli/tests/fixtures/strands/next-rest.strand +++ b/probbit-cli/tests/fixtures/strands/next-line.strand @@ -5,4 +5,4 @@ {"inputs":{"await":true,"elapsed_hours":1,"senses":{"tool_fail":false},"src":"env:runner"},"n":4,"prev":"sha256:6ea32c278c820c3600fa21e8c2ae8b8f0687493338737e52df90c86959649e8c","stance":"sha256:a7278b41acb3fe12967130c9c8bc814cbaff3c0fa62be2f8bb483340505d0207","state":"sha256:e06cfc6167136c9063a2a6dbf8fe0e31a736c2ccdb7f544fadf3aabe482b8bec"} {"checkpoint":4,"prev":"sha256:fe3cd9ff8bb00e09bf1eda46787b30a5b2aec987517157dafc4a082265aee80a","stance":{"credit":{"closed":[],"open":[1,3],"paid":null,"stance":"sha256:c8489fca40c7c1be178879b97fe18d5a0d7b2c0112b9ce4b3d6e80f54bbaceee"},"engine":{"program":"sha256:88b1c7a3a203ed88d93f9ed2c293a03c6eab6e1250d67547908de00aea2ce80b","tier":"enumerate","verdict":"exact"},"escalate":null,"habits":{"active":[],"bound":[],"conflict":[],"violations":0,"yielded":[]},"held":[],"ignored":[],"inputs":{"await":true,"error":false,"praise":false,"senses":{"tool_fail":false},"src":"env:runner"},"line":"Stance: retry the local probe.","line_tokens":8,"mood":{},"noticed":null,"persona":{"digest":"sha256:7025afeaa36b57a02b12333a905f6c378065b398b0bce51999bc5ac513112da6","name":"Retry policy sensed","version":"1"},"probbit_persona_turn":1,"seed":0,"senses":{"tool_fail":{"mean":0.110495,"n":2,"raw":false,"src":"env:runner","z":-0.332458,"zd":0}},"stance":{"action":{"level":"retry","odds":{"ask":0.354834,"retry":0.645166},"p":0.645166,"released":true}},"state_digest":"sha256:e06cfc6167136c9063a2a6dbf8fe0e31a736c2ccdb7f544fadf3aabe482b8bec","status":"ok","turn":3,"unsure":[],"why":"baseline (no live evidence)"},"state":{"control_closed":[],"credit":{"action":[0.354834,-0.354834]},"digest":"sha256:e06cfc6167136c9063a2a6dbf8fe0e31a736c2ccdb7f544fadf3aabe482b8bec","genes":{"react":{},"shift":{}},"history":{},"learned":{"action":[0.048929,-0.048929]},"learned_from":{"action":{"corrections":0,"rewards":1}},"mood":{},"open_credit":[{"credit":{"action":[-0.006061,0.006061]},"stance":"sha256:3a09c0ac90216571ad6197cd3d6ae9f82f0daa3be90d729a4df9e98e96bee783","turn":1},{"credit":{"action":[0.354834,-0.354834]},"stance":"sha256:c8489fca40c7c1be178879b97fe18d5a0d7b2c0112b9ce4b3d6e80f54bbaceee","turn":3}],"persona":{"digest":"sha256:7025afeaa36b57a02b12333a905f6c378065b398b0bce51999bc5ac513112da6","name":"Retry policy sensed","version":"1"},"prev":{"action":"retry"},"prev_stance":"sha256:c8489fca40c7c1be178879b97fe18d5a0d7b2c0112b9ce4b3d6e80f54bbaceee","probbit_persona_state":1,"rest":{"action":"retry"},"seed":0,"senses":{"genes":{"gain":{},"habituation":{},"threshold":{}},"norm":{"tool_fail":{"mean":0.103095,"n":3,"var":0.094497}}},"turn":4}} {"inputs":{"elapsed_hours":0.2,"for":3,"praise":true,"src":"env:ci"},"n":5,"prev":"sha256:0ab36e9ca57b9212e9f91ab83ab232565a8ff4a557fad1cc50f6ef520c2416fe","stance":"sha256:2d29f2748955ef31b703bcdcac6a9235bf218f3335f917291d7d933c856be285","state":"sha256:bac04f1bad4017a683816ac80af1e6bc21a15968c2d7988c631d4efe8cde46c7"} -{"prev":"sha256:b6c61eb3b333dfcf5ee280f232466657bec4da81573cc0cfa8f23525e70973ca","rest":{"rho":0.5}} +{"nap":{"rho":0.5},"prev":"sha256:b6c61eb3b333dfcf5ee280f232466657bec4da81573cc0cfa8f23525e70973ca"} diff --git a/probbit-cli/tests/live.rs b/probbit-cli/tests/live.rs index 23d2b8d..d010114 100644 --- a/probbit-cli/tests/live.rs +++ b/probbit-cli/tests/live.rs @@ -321,8 +321,9 @@ fn probbit_check_holds_a_sensed_persona() { /// sensed-credit.events.jsonl --strand ... --checkpoint-every 4`: a senses block and a learning.credit block, 5 events, a /// checkpoint at line 6); `sensed-credit-0.9.0-dev.verify-0.8.2.txt`, what the released 0.8.2 binary printed on it (it cannot be /// changed); and one version up, derived from the 0.9.0 strand (not recordings: no engine with these exists yet), the header's -/// engine set to "probbit 0.11.0-dev" and every later line re-chained: `next-load.strand` (the header's persona document gains -/// RFC 0002 part 2's `load` block, which no engine has yet, its digest recomputed for it) and `next-rest.strand` (a rest line appended as line 8). +/// engine set to "probbit 0.11.0-dev" and every later line re-chained: `next-block.strand` (the header's persona document gains +/// a `sleep` block, which no engine has, its digest recomputed for it) and `next-line.strand` (a `nap` line appended as line 8). +/// Until 0.10.1 they used RFC 0002 part 2's `load` block and rest line, which 0.10.1 implements (the same bytes, renamed). const STRANDS: &str = "probbit-cli/tests/fixtures/strands"; fn fixture(name: &str) -> String { format!("{STRANDS}/{name}") } fn fixture_text(name: &str) -> String { read(&format!("{}/../{STRANDS}/{name}", env!("CARGO_MANIFEST_DIR"))) } @@ -343,9 +344,9 @@ fn a_newer_engines_strand_is_unsupported_not_a_divergence() { let old = parse(rec.lines().find(|l| !l.starts_with('#')).unwrap()); assert_eq!((s(&old, &["ok"]), s(&old, &["line"])), (&Json::Bool(false), &Json::Num(1.0))); assert!(s(&old, &["diverges"]).as_str().unwrap().starts_with("the persona document: senses: unknown field"), "{rec}"); - // one version up: unsupported, at line 1 for the header's persona, at line 8 for the rest line + // one version up: unsupported, at line 1 for the header's persona, at line 8 for the nap line let actions = tmp("unsupported-actions.jsonl"); std::fs::write(&actions, "{\"turn\":0,\"action\":{\"action\":\"retry\"}}\n").unwrap(); - for (name, line, field) in [("next-load.strand", 1, "load"), ("next-rest.strand", 8, "rest")] { + for (name, line, field) in [("next-block.strand", 1, "sleep"), ("next-line.strand", 8, "nap")] { let want = format!(r#"{{"engine":"{THIS}","line":{line},"ok":false,"strand_engine":"probbit 0.11.0-dev","unsupported":"{field}"}}"#); let say = format!("probbit: live verify: line {line}: unsupported by this engine: {field} (written by probbit 0.11.0-dev; this engine is {THIS}): not verified, and not a divergence"); for extra in [vec![], vec!["--from-checkpoint"], vec!["--actions", actions.as_str()]] { @@ -363,7 +364,7 @@ fn a_newer_engines_strand_is_unsupported_not_a_divergence() { /// as unsupported (never FAIL), fails the gate (exit 1) and names the field and the strand's engine in --json. #[test] fn a_tampered_strand_still_diverges_and_check_says_unsupported() { - let t = fixture_text("sensed-credit-0.9.0-dev.strand"); let load = fixture_text("next-load.strand"); let rest = fixture_text("next-rest.strand"); + let t = fixture_text("sensed-credit-0.9.0-dev.strand"); let load = fixture_text("next-block.strand"); let rest = fixture_text("next-line.strand"); let lines = |x: &str| x.lines().map(str::to_string).collect::>(); let mut cut = lines(&load); cut.remove(2); let mut moved = lines(&rest); let last = moved.len() - 1; @@ -380,14 +381,14 @@ fn a_tampered_strand_still_diverges_and_check_says_unsupported() { clean(&[&f]); } // probbit check: the 0.9.0 strand replays, the newer one is unsupported (its line, its field, the engine that wrote it) - let persona = fixture("sensed-credit.json"); let (ok, nx) = (fixture("sensed-credit-0.9.0-dev.strand"), fixture("next-load.strand")); + let persona = fixture("sensed-credit.json"); let (ok, nx) = (fixture("sensed-credit-0.9.0-dev.strand"), fixture("next-block.strand")); let (c, out, e) = probbit(&["check", &persona, "--seeds", "0-1", "--strand", &ok, "--strand", &nx]); assert_eq!(c, 1, "{out}\n{e}"); assert!(out.lines().any(|l| l.starts_with("strand ") && l.contains(&ok) && l.ends_with(" ok 5 events")), "{out}"); - assert!(out.lines().any(|l| l.starts_with("strand ") && l.contains(&nx) && l.ends_with(" line 1: unsupported by this engine: load (written by probbit 0.11.0-dev)")), "{out}"); + assert!(out.lines().any(|l| l.starts_with("strand ") && l.contains(&nx) && l.ends_with(" line 1: unsupported by this engine: sleep (written by probbit 0.11.0-dev)")), "{out}"); assert!(!out.contains("FAIL") && !out.contains("diverges"), "{out}"); let (c, out, e) = probbit(&["check", &persona, "--seeds", "0-1", "--strand", &nx, "--json"]); assert_eq!(c, 1, "{e}"); let d = parse(&out); let st = &s(&d, &["strands"]).as_arr().unwrap()[0]; - assert_eq!(jw(st), format!(r#"{{"error":"line 1: unsupported by this engine: load","events":null,"final_state":null,"ok":false,"path":"{nx}","strand_engine":"probbit 0.11.0-dev","unsupported":"load"}}"#)); + assert_eq!(jw(st), format!(r#"{{"error":"line 1: unsupported by this engine: sleep","events":null,"final_state":null,"ok":false,"path":"{nx}","strand_engine":"probbit 0.11.0-dev","unsupported":"sleep"}}"#)); assert_eq!((s(&d, &["verdict", "strands_ok"]), s(&d, &["verdict", "exit"])), (&Json::Num(0.0), &Json::Num(1.0))); } diff --git a/python/test_mcp.py b/python/test_mcp.py index dd77e94..f2a2d75 100644 --- a/python/test_mcp.py +++ b/python/test_mcp.py @@ -293,7 +293,7 @@ def test_live_verify_answers_unsupported_for_a_newer_engines_strand(self): code, out = cli(["version"]) self.assertEqual((code, "probbit " + init["serverInfo"]["version"]), (0, out.strip())) st = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "probbit-cli", "tests", "fixtures", "strands") - for name, line, field in (("next-load.strand", 1, "load"), ("next-rest.strand", 8, "rest")): + for name, line, field in (("next-block.strand", 1, "sleep"), ("next-line.strand", 8, "nap")): path = os.path.join(st, name) code, text = cli(["live", "verify", path]) self.assertEqual(code, 5) diff --git a/python/test_persona.py b/python/test_persona.py index d32ea93..fed7e7d 100644 --- a/python/test_persona.py +++ b/python/test_persona.py @@ -141,15 +141,15 @@ def test_live_event_logs_a_strand_that_verifies(self): self.assertEqual(e.exception.path, "events[0].event.elapsed_hours") def test_live_verify_answers_unsupported_for_a_newer_engines_strand(self): - # docs/persona.md 5.7: a block or a line kind this engine does not know (RFC 0002's needs, a rest line) is unsupported + # docs/persona.md 5.7: a block or a line kind this engine does not know (a newer block, a newer line kind) is unsupported # (exit 5), a document as a divergence is; a strand tampered with next to it still diverges at its line import subprocess, tempfile this = subprocess.run([probbit.find_binary(), "version"], capture_output=True, encoding="utf-8").stdout.strip() self.assertTrue(probbit.live_verify(os.path.join(ST, "sensed-credit-0.9.0-dev.strand"))["ok"]) - for name, line, field in (("next-load.strand", 1, "load"), ("next-rest.strand", 8, "rest")): + for name, line, field in (("next-block.strand", 1, "sleep"), ("next-line.strand", 8, "nap")): self.assertEqual(probbit.live_verify(os.path.join(ST, name)), {"ok": False, "line": line, "unsupported": field, "engine": this, "strand_engine": "probbit 0.11.0-dev"}) - with open(os.path.join(ST, "next-rest.strand"), encoding="utf-8") as f: + with open(os.path.join(ST, "next-line.strand"), encoding="utf-8") as f: lines = f.read().splitlines() del lines[3] # event 3 removed: line 4 no longer chains to the line before it with tempfile.TemporaryDirectory() as d: