From 44a57551df7595f263be5e709ad1687071c5fddc Mon Sep 17 00:00:00 2001 From: BitmapAsset Date: Thu, 8 Oct 2026 10:32:40 -0700 Subject: [PATCH] Fix 0.8.0 floor arithmetic, state rounding and surface metadata --- README.md | 4 +- docs/persona.md | 4 +- probbit-cli/src/fuzz.rs | 7 +-- probbit-cli/src/mcp.rs | 2 +- probbit-cli/src/monitor.rs | 8 ++-- probbit-cli/src/persona.rs | 89 ++++++++++++++++++++++++++---------- probbit-cli/tests/monitor.rs | 22 +++++++++ probbit-cli/tests/persona.rs | 54 ++++++++++++++++++++++ python/test_mcp.py | 5 ++ 9 files changed, 160 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 6e8d525..ea66a8c 100644 --- a/README.md +++ b/README.md @@ -498,7 +498,7 @@ Learning here is a capped nudge to a few weights per trait, credited to the leve trait also pushes the others' levels); rule immunity is architectural, not trained. The proofs and counts cover the stance; whether a model writes in it is measured per model. -### Drives: which goal gets the next unit of effort +### Drives: which goal gets the next unit of effort (0.8.0, unreleased) A `drives:` block gives a persona 2-7 goals, each with wanting, afterglow and an expectation. A win moves the individual by its prediction error (the win's size minus what it expected), so repeated equal wins move it less and less; a new output, `pursue`, @@ -518,7 +518,7 @@ A persona without the block gives the same documents as before, byte for byte. M cues, rewards and praises fun on this persona, 100 individuals x 10,000 turns: 0 habit breaks, all 6,743 must-do turns pursued the due chore, and the safety floor (0.1) held on every turn its habits allowed (least odds 0.141), while fun took 85 % of turns. -### Watch it live: `probbit monitor` +### Watch it live: `probbit monitor` (0.8.0, unreleased) `probbit monitor pip.strand` replays a strand with the rules of `live verify` and draws the individual's inner state as live bars: each trait's levels with their odds, the moods, the event's inputs, the habits that bound, the learned weights and the line, in the diff --git a/docs/persona.md b/docs/persona.md index 73453d7..0d9764f 100644 --- a/docs/persona.md +++ b/docs/persona.md @@ -589,7 +589,9 @@ a cue, a deadline on each `--hours` value. `prove` reads the drives as boxes: th range (wanting and afterglow 0 to their caps, the prediction error 0 to `pe_cap` each way), the goal conditions both ways, and, when `pursue` is in the rule's component, its drive terms (wanting, afterglow, deadline urgency, the floor lift at its most over that box) widen the bound. A goal floor is reported held by construction, with the habits that can exclude it (`floors` in the -JSON). On the drives fixture of the tests: "a chore due within 24 h -> pursue chores" is held by construction (its habit); +JSON). If a checked individual's goal-field bounds (including couplings) leave the normal exponential range, its floors are +reported `unknown`: the log-space lift still runs, but the structural proof does not certify floating-point rounding over +every turn in that range. On the drives fixture of the tests: "a chore due within 24 h -> pursue chores" is held by construction (its habit); "`since_pursued.chores` >= 12 -> pursue chores" is unknown, rightly, since `starve_safety` (priority 1) wins over `starve_chores` (priority 0) on a turn where both are due. diff --git a/probbit-cli/src/fuzz.rs b/probbit-cli/src/fuzz.rs index fbf14dc..cc1bd2d 100644 --- a/probbit-cli/src/fuzz.rs +++ b/probbit-cli/src/fuzz.rs @@ -140,7 +140,7 @@ pub fn prove_doc(p: &Persona, props: &[Prop], seeds: &[u64], out: &[Verdict]) -> Json::Obj(f) }).collect(); let mut v = vec![("probbit_persona_prove".into(), n(1.0)), ("persona".into(), Json::Obj(vec![("name".into(), st(&p.name)), ("version".into(), st(&p.version)), ("digest".into(), st(&p.digest))])), ("seeds".into(), Json::Arr(seeds.iter().map(|x| n(*x as f64)).collect())), ("properties".into(), Json::Arr(props_j))]; - if let Some(f) = persona::floor_verdicts(p) { v.push(("floors".into(), Json::Arr(f))); } // drives (§2.9): goal floors + if let Some(f) = persona::floor_verdicts(p, seeds) { v.push(("floors".into(), Json::Arr(f))); } // drives (§2.9): goal floors Json::Obj(v) } /// The human report of `prove` @@ -156,10 +156,11 @@ pub fn prove_human(p: &Persona, path: &str, props: &[Prop], seeds: &[u64], out: o.push(format!(" seed {sd}: {why}; cell {}", persona::canon(cell))); o.push(format!(" search it: probbit persona fuzz {} --seeds {} --never {}", word(path), ranges(seeds), word(&flow(&pr.rule)))); } } } - for f in persona::floor_verdicts(p).unwrap_or_default() { let g = |k: &str| f.get(k).cloned().unwrap_or(Json::Null); + for f in persona::floor_verdicts(p, seeds).unwrap_or_default() { let g = |k: &str| f.get(k).cloned().unwrap_or(Json::Null); let by: Vec = g("excluded_by").as_arr().unwrap_or(&[]).iter().filter_map(|x| x.as_str().map(str::to_string)).collect(); o.push(String::new()); o.push(format!("floor {} odds >= {}", g("goal").as_str().unwrap_or(""), persona::canon(&g("floor")))); - o.push(format!(" held by construction (the odds lift; on turns whose habits allow it{})", if by.is_empty() { String::new() } else { format!("; can be excluded by {}", by.join(", ")) })); } + if g("verdict").as_str() == Some("unknown") { o.push(format!(" unknown {}", g("reason").as_str().unwrap_or("numeric floor guarantee not certified"))); } + else { o.push(format!(" held by construction (the odds lift; on turns whose habits allow it{})", if by.is_empty() { String::new() } else { format!("; can be excluded by {}", by.join(", ")) })); } } o.join("\n") } diff --git a/probbit-cli/src/mcp.rs b/probbit-cli/src/mcp.rs index ee223bf..403dae8 100644 --- a/probbit-cli/src/mcp.rs +++ b/probbit-cli/src/mcp.rs @@ -204,7 +204,7 @@ fn tools() -> Json { for (k, x) in v.iter_mut() { if k == "description" { *x = jstr("A probbit-ir v1 program (docs/probbit-ir-json.md): variables over named values, scores, pairs, caps and the v2 constructs, plus optional flags."); } if k == "properties" { if let Json::Obj(pr) = x { pr.push(("flags".into(), p(&run_flags))); } } } } let tool = |name: &str, title: &str, desc: &str, schema: Json| obj(vec![("name", jstr(name)), ("title", jstr(title)), ("description", jstr(desc)), ("inputSchema", schema), - ("annotations", obj(vec![("readOnlyHint", Json::Bool(true)), ("openWorldHint", Json::Bool(false))]))]); + ("annotations", obj(vec![("readOnlyHint", Json::Bool(name != "probbit_live_event")), ("destructiveHint", Json::Bool(false)), ("openWorldHint", Json::Bool(false))]))]); Json::Arr(vec![ tool("probbit_decide", "Route tasks to workers", "probbit decide: assign every task to one worker under hard rules (allowed sets, quotas, clamps) with workflow affinity. Returns the same JSON as the CLI: a plan that obeys every rule, odds per task, verdict exact | diagnostics_passed | partial (act on `released`, escalate `escalated`) | refused | infeasible, gate diagnostics and telemetry.", p(&router)), tool("probbit_run", "Run a probbit-ir program", "probbit run: a general constrained categorical program (variables, values, scores, pairs, caps, all_different, implies, tables, precedes, linear). Same answer shape as probbit_decide with `marginals` instead of `odds`.", ir), diff --git a/probbit-cli/src/monitor.rs b/probbit-cli/src/monitor.rs index 7df463a..f8f9b42 100644 --- a/probbit-cli/src/monitor.rs +++ b/probbit-cli/src/monitor.rs @@ -162,6 +162,8 @@ fn short(d: &str) -> &str { let h = d.strip_prefix("sha256:").unwrap_or(d); &h[. /// One frame: the lines to draw (without line endings). `note`: a warning to show (a truncated or rotated strand, an /// incomplete last line). fn frame(w: Option<&Watch>, bad_head: Option<&(usize, String)>, path: &str, note: Option<&str>, s: &Style) -> Vec { + // Strip data's C0, DEL and C1 controls per plain line; the caller adds the frame's line endings. + let finish = |mut lines: Vec| { if s.ascii { for line in &mut lines { line.retain(|c| !c.is_control()); } } lines }; let dot = s.paint(DIM, s.g("·", "|")); let lab = |t: &str| s.bold(MAGENTA, &format!("{t:<8}")); let mut out = vec![]; @@ -171,7 +173,7 @@ fn frame(w: Option<&Watch>, bad_head: Option<&(usize, String)>, path: &str, note out.push(format!("{} {dot} {badge}", s.bold(CYAN, "probbit monitor"))); if let Some(t) = note { out.push(s.paint(YELLOW, t)); } out.push(footer(path, "", s)); - return out; + return finish(out); }; let m = &w.meta; let f = w.last.as_ref(); @@ -182,7 +184,7 @@ fn frame(w: Option<&Watch>, bad_head: Option<&(usize, String)>, path: &str, note let Some(f) = f else { out.push(s.paint(GREY, "no events yet: the strand holds its header and no event lines")); out.push(footer(path, &m.engine, s)); - return out; + return finish(out); }; let doc = &f.doc; let idw = m.traits.iter().chain(&m.moods).map(|v| v.id.chars().count()).chain(m.learning.iter().flat_map(|l| l.0.iter().map(|t| t.chars().count()))).max().unwrap_or(4).clamp(4, 14); @@ -253,7 +255,7 @@ fn frame(w: Option<&Watch>, bad_head: Option<&(usize, String)>, path: &str, note if let Some(y) = doc.get("why").and_then(Json::as_str).filter(|y| !y.is_empty()) { out.push(format!("{}{}", lab(""), s.paint(GREY, &format!("why: {y}")))); } // 8. the footer out.push(footer(path, &m.engine, s)); - out + finish(out) } /// The footer: the strand, the engine that wrote it and the command that replays it; the demo's week (path "") is in memory, so /// the command that writes the same week instead diff --git a/probbit-cli/src/persona.rs b/probbit-cli/src/persona.rs index 30dfc83..a77617f 100644 --- a/probbit-cli/src/persona.rs +++ b/probbit-cli/src/persona.rs @@ -519,7 +519,8 @@ impl DState { let per = |k: &str, ok: &dyn Fn(&Json) -> bool, msg: String| -> R> { let path = at("state.drives", k); let o = get(kv, k).and_then(Json::as_obj).filter(|o| o.len() == d.goals.len() && d.goals.iter().all(|g| get(o, &g.id).is_some_and(ok))).ok_or_else(|| perr(&path, msg))?; Ok(d.goals.iter().map(|g| get(o, &g.id).unwrap().clone()).collect()) }; - let within = |lo: f64, hi: f64| move |x: &Json| x.as_f64().is_some_and(|v| v >= lo && v <= hi); + // the state keeps 6 decimals, so a value clamped to a cap with more decimals can round up to half a unit past it + let within = |lo: f64, hi: f64| move |x: &Json| x.as_f64().is_some_and(|v| v >= lo - 5e-7 && v <= hi + 5e-7); let nums = |v: Vec| v.iter().map(|x| x.as_f64().unwrap()).collect::>(); let want = nums(per("want", &within(0.0, d.cap_w), format!("one number per goal in [0, {}]", pyn(d.cap_w)))?); let glow = nums(per("glow", &within(0.0, d.cap_a), format!("one number per goal in [0, {}]", pyn(d.cap_a)))?); @@ -772,8 +773,13 @@ fn lift_iter(p: &Persona, d: &Drives, own: &[f64], other: &[f64], allowed: &[boo let mut l = vec![0.0; n]; let idx: Vec = (0..n).filter(|&i| d.goals[i].floor > 0.0 && allowed[i]).collect(); for _ in 0..60 { let mut moved = false; for &s in &idx { let f = d.goals[s].floor; - let others: Vec = (0..n).filter(|&g| g != s && allowed[g]).map(|g| (other[g] + l[g] + hi[g]).exp()).collect(); if others.is_empty() { continue; } - let need = (f / (1.0 - f)).ln() + others.iter().fold(0.0, |a, b| a + b).ln() - own[s] - lo[s] + 1e-5; let need = (need * 1e6).ceil() / 1e6; + // log-sum-exp over the other goals (shifted by their max): finite for every finite field. exp() of the raw fields + // underflowed below about -745 nats (no lift: the floor goal fell to odds 0) and overflowed above about +709 (an + // infinite lift: the turn fell back). + let xs: Vec = (0..n).filter(|&g| g != s && allowed[g]).map(|g| other[g] + l[g] + hi[g]).collect(); if xs.is_empty() { continue; } + let m = xs.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b)); + let lse = m + xs.iter().map(|x| (x - m).exp()).sum::().ln(); + let need = (f / (1.0 - f)).ln() + lse - own[s] - lo[s] + 1e-5; let need = (need * 1e6).ceil() / 1e6; if need > l[s] + 1e-9 { l[s] = need; moved = true; } } if !moved { break; } } l.into_iter().map(r6).collect() @@ -935,7 +941,7 @@ impl State { let o = get(kv, k).and_then(Json::as_obj).ok_or_else(|| perr(&path, "required: an object of learned traits"))?; need(o.len() == l.traits.len(), &path, "one entry per learned trait")?; l.traits.iter().map(|t| { let n = p.var(t).unwrap().levels.len(); let tp = at(&path, t); - let a = get(o, t).and_then(Json::as_arr).filter(|a| a.len() == n && a.iter().all(|x| x.as_f64().is_some_and(|x| x.abs() <= cap))).ok_or_else(|| perr(&tp, format!("one number per level, each within ±{}", pyn(cap))))?; + let a = get(o, t).and_then(Json::as_arr).filter(|a| a.len() == n && a.iter().all(|x| x.as_f64().is_some_and(|x| x.abs() <= cap + 5e-7))).ok_or_else(|| perr(&tp, format!("one number per level, each within ±{}", pyn(cap))))?; Ok((t.clone(), a.iter().map(|x| x.as_f64().unwrap()).collect())) }).collect() }; let (learned, credit) = (tab("learned", p.learning.as_ref().map_or(0.0, |l| l.total_cap))?, tab("credit", 1.0)?); let drives = match &p.drives { None => None, Some(d) => Some(DState::read(p, d, get(kv, "drives").unwrap(), seed)?) }; @@ -1443,39 +1449,56 @@ pub fn floor_notes(p: &Persona) -> Option> { ("note".into(), Json::Str("a habit outranks a floor: on turns this habit is in force the goal's odds are 0".into()))])); } } } Some(out) } +/// Unary goal-field bounds for one individual, before floor lifts and couplings. +fn floor_fields(p: &Persona, d: &Drives, seed: u64) -> (Vec, Vec) { + let (n, v) = (d.goals.len(), p.var("pursue").unwrap()); let span = |w: f64, cap: f64| ((w * cap).min(0.0), (w * cap).max(0.0)); + let (sw, sa, sd) = (span(d.w_want, d.cap_w), span(d.w_glow, d.cap_a), span(d.w_deadline, 1.0)); + let lcap = p.learning.as_ref().filter(|l| l.traits.iter().any(|t| t == "pursue")).map_or(0.0, |l| l.total_cap); + let eff = |e: &Eff| e.iter().find(|(t, _)| t == "pursue").map(|(_, w)| w.clone()); + let (gj, dg) = (genes(p, seed), drive_genes(p, d, seed)); let react = |id: &str| gj.get("react").and_then(|r| r.get(id)).and_then(Json::as_f64).unwrap_or(1.0); + let gene = |i: usize| dg.get("interest").and_then(|o| o.get(&d.goals[i].id)).and_then(Json::as_f64).unwrap_or(0.0); + let mut lo: Vec = (0..n).map(|i| v.base[i] + gene(i) - lcap + sw.0 + sa.0 + sd.0).collect(); let mut hi: Vec = (0..n).map(|i| v.base[i] + gene(i) + lcap + sw.1 + sa.1 + sd.1).collect(); + let mut add = |opts: Vec>| { for i in 0..n { lo[i] += opts.iter().map(|w| w[i]).fold(f64::INFINITY, f64::min); hi[i] += opts.iter().map(|w| w[i]).fold(f64::NEG_INFINITY, f64::max); } }; + for x in &p.inputs { let g = react(&x.id); match x.kind { + Kind::Flag => if let Some(e) = eff(&x.effects) { add(vec![vec![0.0; n], e.iter().map(|w| g * w).collect()]); }, + Kind::Number => if let Some(e) = eff(&x.effects) { add(vec![vec![0.0; n], e.iter().map(|w| g * x.max * w).collect()]); }, + Kind::Level => add(x.levels.iter().map(|l| x.by_level.iter().find(|(b, _)| b == l).and_then(|(_, e)| eff(e)).map_or(vec![0.0; n], |e| e.iter().map(|w| g * w).collect())).collect()) } } + for h in &p.history { if let Some(e) = eff(&h.effects) { add(vec![vec![0.0; n], e.iter().map(|w| h.cap as f64 * w).collect()]); } } + (lo, hi) +} /// `lint` for floors (§2.9): a floor goal whose lift can reach 50 on some turn of the given individuals: the lift's iteration with /// the goal's unary at its least and every other goal's at its most over everything the persona can feed `pursue` (prior, the /// seed's drive genes, learned ±total_cap, every input and history effect at its extremes, the drive box) pub fn floor_reach(p: &Persona, seeds: &[u64]) -> Vec { - let (Some(d), Some(pi)) = (&p.drives, p.vars.iter().position(|v| v.id == "pursue")) else { return vec![] }; + let Some(d) = &p.drives else { return vec![] }; if !d.goals.iter().any(|g| g.floor > 0.0) { return vec![]; } - let (n, v) = (d.goals.len(), &p.vars[pi]); let span = |w: f64, cap: f64| ((w * cap).min(0.0), (w * cap).max(0.0)); - let (sw, sa, sd) = (span(d.w_want, d.cap_w), span(d.w_glow, d.cap_a), span(d.w_deadline, 1.0)); - let lcap = p.learning.as_ref().filter(|l| l.traits.iter().any(|t| t == "pursue")).map_or(0.0, |l| l.total_cap); - let eff = |e: &Eff| e.iter().find(|(t, _)| t == "pursue").map(|(_, w)| w.clone()); - let mut most = vec![0.0f64; n]; - for &seed in seeds { - let (gj, dg) = (genes(p, seed), drive_genes(p, d, seed)); let react = |id: &str| gj.get("react").and_then(|r| r.get(id)).and_then(Json::as_f64).unwrap_or(1.0); - let gene = |i: usize| dg.get("interest").and_then(|o| o.get(&d.goals[i].id)).and_then(Json::as_f64).unwrap_or(0.0); - let mut lo: Vec = (0..n).map(|i| v.base[i] + gene(i) - lcap + sw.0 + sa.0 + sd.0).collect(); let mut hi: Vec = (0..n).map(|i| v.base[i] + gene(i) + lcap + sw.1 + sa.1 + sd.1).collect(); - let mut add = |opts: Vec>| { for i in 0..n { lo[i] += opts.iter().map(|w| w[i]).fold(f64::INFINITY, f64::min); hi[i] += opts.iter().map(|w| w[i]).fold(f64::NEG_INFINITY, f64::max); } }; - for x in &p.inputs { let g = react(&x.id); match x.kind { - Kind::Flag => if let Some(e) = eff(&x.effects) { add(vec![vec![0.0; n], e.iter().map(|w| g * w).collect()]); }, - Kind::Number => if let Some(e) = eff(&x.effects) { add(vec![vec![0.0; n], e.iter().map(|w| g * x.max * w).collect()]); }, - Kind::Level => add(x.levels.iter().map(|l| x.by_level.iter().find(|(b, _)| b == l).and_then(|(_, e)| eff(e)).map_or(vec![0.0; n], |e| e.iter().map(|w| g * w).collect())).collect()) } } - for h in &p.history { if let Some(e) = eff(&h.effects) { add(vec![vec![0.0; n], e.iter().map(|w| h.cap as f64 * w).collect()]); } } + let n = d.goals.len(); let mut most = vec![0.0f64; n]; + for &seed in seeds { let (lo, hi) = floor_fields(p, d, seed); for (m, l) in most.iter_mut().zip(lift_iter(p, d, &lo, &hi, &vec![true; n])) { *m = m.max(l); } } d.goals.iter().zip(most).filter(|(g, l)| g.floor > 0.0 && *l >= 50.0).map(|(g, l)| Json::Obj(vec![("goal".into(), Json::Str(g.id.clone())), ("floor".into(), Json::Num(g.floor)), ("lift_max".into(), Json::Num(r6(l))), ("note".into(), Json::Str("the floor's lift can reach 50 or more: it outweighs everything the persona can say for or against this goal".into()))])).collect() } /// `prove` for a drives block's floors (§2.9, design §5.2): each floor holds by construction of the odds lift, on every turn whose /// habits allow the goal (the soundness condition, no multi-variable rule over `pursue`, is checked when the persona is read); -/// with the habits that can exclude it. None without floors. -pub fn floor_verdicts(p: &Persona) -> Option> { +/// with the habits that can exclude it. Extreme numeric ranges are unknown. None without floors. +pub fn floor_verdicts(p: &Persona, seeds: &[u64]) -> Option> { let d = p.drives.as_ref()?; let notes = floor_notes(p)?; if !d.goals.iter().any(|g| g.floor > 0.0) { return None; } + // Keep the construction verdict within the normal exponential range. Extreme ranges use the log-space lift at + // runtime, but this structural proof does not certify their floating-point rounding over every possible turn. + let normal = !seeds.is_empty() && seeds.iter().all(|&seed| { + let (mut lo, mut hi) = floor_fields(p, d, seed); + for (a, b, tab) in &p.couplings { + let rows: Vec> = if a == "pursue" { tab.clone() } else if b == "pursue" { (0..lo.len()).map(|i| tab.iter().map(|r| r[i]).collect()).collect() } else { continue }; + for (i, r) in rows.iter().enumerate() { lo[i] += r.iter().copied().fold(f64::INFINITY, f64::min); hi[i] += r.iter().copied().fold(f64::NEG_INFINITY, f64::max); } + } + lo.iter().chain(&hi).all(|x| x.is_finite() && *x >= f64::MIN_POSITIVE.ln() && *x <= f64::MAX.ln()) + }); Some(d.goals.iter().filter(|g| g.floor > 0.0).map(|g| { let by: Vec = notes.iter().filter(|n| n.get("goal").and_then(Json::as_str) == Some(g.id.as_str())) .filter_map(|n| n.get("habit").and_then(Json::as_str).map(str::to_string)).collect(); - Json::Obj(vec![("goal".into(), Json::Str(g.id.clone())), ("floor".into(), Json::Num(g.floor)), ("verdict".into(), Json::Str("held_by_construction".into())), ("excluded_by".into(), jstrs(&by))]) }).collect()) + let mut fields = vec![("goal".into(), Json::Str(g.id.clone())), ("floor".into(), Json::Num(g.floor)), + ("verdict".into(), Json::Str(if normal { "held_by_construction" } else { "unknown" }.into())), ("excluded_by".into(), jstrs(&by))]; + if !normal { fields.push(("reason".into(), Json::Str("goal field bounds leave the normal exponential range; the numeric floor guarantee is not certified".into()))); } + Json::Obj(fields) }).collect()) } /// `lint`'s decoding check (a warning, not an error): the stance is the joint plan, the most likely stance as a whole, so on /// coupled traits a trait's planned level can differ from its own most likely level (its marginal mode). Probes: each seed's @@ -2024,6 +2047,22 @@ 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() } + /// 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() { + let p = persona(&LEARN.replace("0.6", "0.1234567").replace("0.2", "0.1234567")).unwrap(); + for sign in [-1.0, 1.0] { for (value, ok) in [(0.123457, true), (0.123458, false)] { + let mut st = init(&p, None, true, &run); st.learned[0].1[0] = sign * value; st.seal(&p); + assert_eq!(State::read(&p, &st.to_json(&p)).is_ok(), ok, "learned {sign} * {value}"); + } } + let p = build(&json::parse(&ADV.replace(r#""learn_from_surprise":0.5"#, r#""learn_from_surprise":0.5,"wanting":{"cap":0.1234567}"#)).unwrap()).unwrap(); + assert_eq!(p.drives.as_ref().unwrap().cap_w, 0.1234567); + for (value, ok) in [(0.123457, true), (0.123458, false), (-0.000001, false)] { + let mut st = init(&p, None, true, &run); st.drives.as_mut().unwrap().want[0] = value; st.seal(&p); + assert_eq!(State::read(&p, &st.to_json(&p)).is_ok(), ok, "want {value}"); + } + } + /// Every turn's learned deltas equal the rule applied to the previous stance document's levels and odds (released traits; others 0): /// Δ += clip(sign · rate · (onehot − odds), ±step_cap), recentred to sum 0, clipped to ±total_cap, 6 decimals; no feedback /// (or reward and correction together) moves nothing; the deltas sum to 0 until a cap clips; a habit-forced level gives no credit @@ -2283,8 +2322,8 @@ mod tests { let p = adv(); let rule = |t: &str| json::parse(t).unwrap(); let pr = |t: &str| props(&p, &rule(t), "--never", "never"); let must = pr(r#"{"when":{"goal.chores.deadline_hours":{"at_most":24}},"then":{"pursue":["chores"]}}"#).unwrap(); assert_eq!(by_construction(&p, &must[0], &run), Some(vec!["chores_due".to_string()])); - assert_eq!(canon(&Json::Arr(floor_verdicts(&p).unwrap())), r#"[{"excluded_by":["chores_due","starve_chores"],"floor":0.1,"goal":"safety","verdict":"held_by_construction"}]"#); - assert!(floor_verdicts(&persona("").unwrap()).is_none()); + assert_eq!(canon(&Json::Arr(floor_verdicts(&p, &[0]).unwrap())), r#"[{"excluded_by":["chores_due","starve_chores"],"floor":0.1,"goal":"safety","verdict":"held_by_construction"}]"#); + assert!(floor_verdicts(&persona("").unwrap(), &[0]).is_none()); assert_eq!(err(pr(r#"{"when":{"goal.chores.deadline_hours":{"at_most":12}},"then":{"pursue":["chores"]}}"#)), "--never.when.goal.chores.deadline_hours: a property reads the goal conditions the persona's habits use: goal.chores.deadline_hours <= 24, since_pursued.chores >= 12, since_pursued.safety >= 24"); assert_eq!(err(pr(r#"{"when":{"drv_want":1},"then":{"pursue":["chores"]}}"#)), "--never.when.drv_want: reserved for the drives block"); diff --git a/probbit-cli/tests/monitor.rs b/probbit-cli/tests/monitor.rs index 7e28c70..cb3b99c 100644 --- a/probbit-cli/tests/monitor.rs +++ b/probbit-cli/tests/monitor.rs @@ -84,6 +84,28 @@ fn exit_codes() { for f in [bent, part, bare, not] { let _ = std::fs::remove_file(f); } } +/// Check that plain frames strip C0, DEL and C1 controls from version and phrase strings, including a header-only strand. +#[test] +fn plain_frames_strip_persona_controls() { + let p = format!("{}.json", tmp("control-persona")); let strand = tmp("control"); + let doc = r#"{"probbit_persona":1,"identity":{"name":"Fixture","version":"1\u001b]0;TITLE_CHECK\u0007\r\n\t\u007f\u0085\u009b"}, + "traits":[{"id":"tone","levels":["low","high"],"say":["plain\u001b[31m\u0007","plain\u001b[31m\u0007"]}], + "drives":{"goals":[{"id":"work","say":"work\u001b[31m\u0007"},{"id":"rest","say":"rest\u001b[31m\u0007"}]}}"#; + std::fs::write(&p, doc).unwrap(); + let mut live = Command::new(env!("CARGO_BIN_EXE_probbit")).args(["live", &p, "--clock", "fixed", "--strand", &strand]) + .stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + live.stdin.take().unwrap().write_all(b"{}\n").unwrap(); + let o = live.wait_with_output().unwrap(); assert!(o.status.success(), "{:?}", o); + let full = std::fs::read_to_string(&strand).unwrap(); + for text in [full.clone(), full.lines().next().unwrap().to_string() + "\n"] { + std::fs::write(&strand, text).unwrap(); + let (c, out, err) = probbit(&["monitor", &strand, "--once", "--plain"]); assert_eq!(c, 0, "{err}"); + assert!(!out.chars().any(|c| c.is_control() && c != '\n'), "{out:?}"); + assert!(out.lines().next().unwrap().contains("Fixture 1]0;TITLE_CHECK | seed"), "{out:?}"); + } + for f in [p, strand] { let _ = std::fs::remove_file(f); } +} + /// A running child, killed when the test ends, passing or not (a failed assert must not leave a server or a follower running) struct Reap(std::process::Child); impl Drop for Reap { fn drop(&mut self) { let _ = self.0.kill(); let _ = self.0.wait(); } } diff --git a/probbit-cli/tests/persona.rs b/probbit-cli/tests/persona.rs index 68bd52d..5834667 100644 --- a/probbit-cli/tests/persona.rs +++ b/probbit-cli/tests/persona.rs @@ -25,6 +25,60 @@ fn parse(s: &str) -> Json { json::parse(s).unwrap_or_else(|e| panic!("not JSON ( fn jw(j: &Json) -> String { json::write(j, false) } fn s<'a>(j: &'a Json, path: &[&str]) -> &'a Json { path.iter().fold(j, |x, k| x.get(k).unwrap_or_else(|| panic!("no {k} in {:.200}", jw(x)))) } const NAMES: [&str; 3] = ["ops-engineer", "tutor", "trader-assistant"]; + +/// Check finite floor lifts on both sides of the raw exponential's range, including subnormal values. +#[test] +fn floor_lifts_hold_across_extreme_fields() { + let file = tmp("floor-range.json"); + for effects in [r#"{"fun":-10,"safety":-20}"#, r#"{"fun":10}"#, r#"{"fun":10,"safety":20}"#] { + let doc = format!(r#"{{"probbit_persona":1,"identity":{{"name":"Fixture","version":"1"}}, + "traits":[{{"id":"tone","levels":["low","mid","high"],"prior":[0.2,0.5,0.3]}}], + "inputs":[{{"id":"pressure","kind":"number","max":100,"effects":{{"pursue":{effects}}}}}], + "drives":{{"goals":[{{"id":"fun"}},{{"id":"safety","floor":0.1}}]}}}}"#); + std::fs::write(&file, doc).unwrap(); + for json in [false, true] { + let mut args = vec!["persona", "prove", &file, "--never", r#"{"then":{"tone":["mid"]}}"#, "--seeds", "0"]; + if json { args.push("--json"); } + let (c, out, err) = probbit(&args, ""); assert_eq!(c, 0, "{err}"); + if json { + let proof = parse(&out); let floor = &s(&proof, &["floors"]).as_arr().unwrap()[0]; + assert_eq!(s(floor, &["verdict"]).as_str(), Some("unknown"), "{out}"); + } else { assert!(out.contains("floor safety") && out.contains("numeric floor guarantee is not certified"), "{out}"); } + } + for pressure in [50, 74, 75, 100] { + let script = format!(r#"[{{"pressure":{pressure}}}]"#); + let (c, out, err) = probbit(&["persona", "replay", &file, "--script", &script], ""); + assert_eq!(c, 0, "{err}"); + let d = parse(&out); + assert_eq!(s(&d, &["status"]).as_str(), Some("ok"), "{out}"); + assert_eq!(s(&d, &["pursue", "released"]), &Json::Bool(true), "{out}"); + assert!(s(&d, &["pursue", "odds", "safety"]).as_f64().unwrap() >= 0.1, "{out}"); + assert!(s(&d, &["pursue", "lift"]).as_obj().unwrap().iter().all(|(_, v)| v.as_f64().is_some_and(f64::is_finite)), "{out}"); + } + } + let _ = std::fs::remove_file(file); +} + +/// Check that six-decimal state output can be read with a cap that has seven decimals. +#[test] +fn rounded_learning_and_drive_caps_continue() { + for (kind, block, inputs) in [ + ("learning", r#""inputs":[{"id":"praise","kind":"flag"}],"learning":{"from":["praise"],"traits":["tone"],"rate":0.5,"step_cap":0.1234567,"total_cap":0.1234567}"#, r#"{"praise":true}"#), + ("drive", r#""drives":{"goals":[{"id":"fun"},{"id":"safety"}],"wanting":{"cap":0.1234567}}"#, r#"{"goals":{"fun":{"cue":true}}}"#), + ] { + let file = tmp(&format!("{kind}-cap.json")); let state = tmp(&format!("{kind}-cap-state.json")); + let doc = format!(r#"{{"probbit_persona":1,"identity":{{"name":"Fixture","version":"1"}}, + "traits":[{{"id":"tone","levels":["low","mid","high"],"prior":[0.2,0.5,0.3]}}],{block}}}"#); + std::fs::write(&file, doc).unwrap(); + let (c, out, err) = probbit(&["persona", "init", &file, "--out", &state], ""); assert_eq!(c, 0, "{out}{err}"); + for event in ["{}", inputs] { + let (c, out, err) = probbit(&["persona", "turn", &file, "--state", &state, "--inputs", event], ""); assert_eq!(c, 0, "{out}{err}"); + } + let saved = read(&state); assert!(saved.contains("0.123457"), "{saved}"); + let (c, out, err) = probbit(&["persona", "turn", &file, "--state", &state, "--inputs", "{}"], ""); assert_eq!(c, 0, "{out}{err}"); + for f in [file, state] { let _ = std::fs::remove_file(f); } + } +} fn workday() -> Vec { s(&parse(&read(&ex("workday.json"))), &["turns"]).as_arr().unwrap().to_vec() } struct Mix(u64); diff --git a/python/test_mcp.py b/python/test_mcp.py index 70f6025..74fcbc6 100644 --- a/python/test_mcp.py +++ b/python/test_mcp.py @@ -190,6 +190,11 @@ def test_persona_fuzz_answers_exactly_as_the_cli(self): def test_live_event_logs_a_strand_the_cli_verifies(self): self.legacy("2025-11-25") + tools = self.c.request("tools/list")["result"]["tools"] + for tool in tools: + self.assertEqual(tool["annotations"]["readOnlyHint"], tool["name"] != "probbit_live_event") + event_tool = next(t for t in tools if t["name"] == "probbit_live_event") + self.assertFalse(event_tool["annotations"]["destructiveHint"]) path = os.path.join(EX, "persona", "tutor.yaml") with tempfile.TemporaryDirectory() as d: strand = os.path.join(d, "pip.strand")