diff --git a/CHANGELOG.md b/CHANGELOG.md index 19560ff..9cf56cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,22 @@ All notable changes to this project are documented here. The format follows the safety floor of 0.1 held on every turn its habits allowed (least odds 0.141), and every individual ends in the state the Python prototype of the design reaches on the same events (100 of 100 digests). +- **`probbit monitor STRAND`** (docs/persona.md §5.8, probbit-cli/src/monitor.rs): watch an individual's inner state as live + horizontal bars. It replays the strand with the rules of `live verify` (`live::Replay`, now shared by both), recomputing every + stance document from the strand alone, and draws the latest event: each trait's levels with their odds (the level taken + highlighted, its phrase), the moods with a sparkline of the last 50 events, the event's inputs and history features, the habits + in force (the bound ones marked, the violations counter), the learned deltas within their cap, the drives when a document + carries them, and the stance line. `--follow` replays appended lines within a second (a truncated or rotated strand is replayed + from the start, with a warning), `--once` prints one frame, `--plain` draws in plain ASCII. `probbit monitor --demo` plays the + tutor's scripted week (the week `live --demo week` writes, seed 2, through a temporary file it removes at once), paced 1 s per + hour and each night in 2 s. Exit 0, 1 at a line that differs (named in the frame), 2 for a bad flag or a file that is not a + strand. It changes no file and sends nothing; no dependencies. +- **`probbit monitor STRAND --serve [--port N] [--open]`** (also with `--demo`): the same board as a page in the browser. A server + on 127.0.0.1 (std `TcpListener`; it binds no other address, and refuses requests addressed to another host) follows the strand + and serves one page embedded in the binary (styles and script inline; it fetches no fonts, styles or scripts), its server-sent + events (the layout, the latest frame, then a frame per event, a heartbeat every 15 s) and `/doc/N`, event N's stance document + as `probbit live` printed it. The URL is the line on stdout; `--open` serves the same and starts the default browser, best + effort. `probbit monitor --demo --open` plays the week over and over in the browser. ### Changed - MCP: the input schemas of `probbit_persona_turn` (`inputs`) and `probbit_live_event` (`event`) declare `goals`, so a client that checks arguments against them passes a drives persona's goal signals. diff --git a/README.md b/README.md index 7b8fc00..6e8d525 100644 --- a/README.md +++ b/README.md @@ -518,6 +518,19 @@ 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` + +`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 +terminal (`--follow` redraws as the strand grows) or as a page on 127.0.0.1 (`--serve`). It reads the strand and sends nothing +anywhere ([docs/persona.md](docs/persona.md) §5.8). No strand yet? The tutor's week, in under a minute: + +```sh +probbit monitor --demo --open +``` + +![probbit monitor: the tutor's week in the browser, the bars moving with every event](docs/probbit-monitor.gif) + ## After any judge: `probbit evaluate` Decision models (TypeSafe's Jev, Cloudflare's Clef and Clef-flash, local System One servers) are judges: content in, a @@ -701,7 +714,8 @@ or Windows. - Hardware backend through `probbit-ir` when a p-bit fabric is available. ## Documentation map -- [docs/CHEATSHEET.md](docs/CHEATSHEET.md): one page: the verdicts, the tiers, `bound`, the exit codes, every persona and live command. +- [docs/CHEATSHEET.md](docs/CHEATSHEET.md): one page: the verdicts, the tiers, `bound`, the exit codes, every persona, live and + monitor command. - [BENCHMARKS.md](BENCHMARKS.md): every number, with machine, load and `N`. - [USE-CASES.md](USE-CASES.md): what the measurements support, by problem shape and by industry. - [docs/probbit-ir-json.md](docs/probbit-ir-json.md): the `probbit-ir` JSON v1 wire format, instructions, resource controls, limits. @@ -710,8 +724,8 @@ or Windows. - [docs/agents.md](docs/agents.md): calling probbit from a shell, Python, Node, PowerShell, MCP agents and a browser, and `probbit evaluate` after a decision model. - [docs/persona.md](docs/persona.md): `probbit persona`, the individuality layer: the persona file, the compilation, the stance and - state documents, the canonical JSON, testing a character, bounded learning and `probbit live` (§2.8, §5.7), what a persona can - not do; [examples/persona/](examples/persona/): three personas + goldens. + state documents, the canonical JSON, testing a character, bounded learning, `probbit live` and `probbit monitor` (§2.8, §5.7, + §5.8), what a persona can not do; [examples/persona/](examples/persona/): three personas + goldens. - [python/](python/): `probbit.py`, a zero-dependency subprocess wrapper (with `evaluate`, the persona functions and a stdlib mock judge), its tests and three examples. - [playground/](playground/): one static page that runs probbit in a browser (`probbit-wasm`, built by `playground/build.sh`). diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index 0e1185a..b7e1462 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -36,6 +36,7 @@ The exact tiers run before the sampler: - `persona`: 0 done; 1 lint found an unresolved contradiction or (with rules) a counterexample, fuzz found a counterexample or prove left a rule unknown; 2 bad persona / state / inputs / script / rule, or bad flag. - `live`: 0 done; 1 verify: a line differs; 2 a bad persona, state, event or flag. +- `monitor`: 0 every line replays; 1 a line differs; 2 a bad flag, a port that cannot be had, or a file that is not a strand. ## `probbit persona PERSONA` | sub | does | @@ -52,12 +53,14 @@ The exact tiers run before the sampler: | `compile --state STATE [--inputs ...]` | the turn's probbit-ir program | | `describe` | traits, moods, inputs, habits, agenda | -## `probbit live` +## `probbit live`, `probbit monitor` | command | does | |---|---| | `live PERSONA [--seed N \| --state FILE] [--strand FILE] [--events FILE]` | JSONL events in, one stance per event out | | `live PERSONA --demo week [--seed N] [--plain]` | a scripted week: learning to a cap, a night, rules that hold | | `live verify STRAND` | replay a strand; the earliest line that differs | +| `monitor STRAND [--follow] [--plain] [--serve] [--open] [--port N]` | the strand replayed as live bars: the terminal, or a page on 127.0.0.1 | +| `monitor --demo [--open]` | the tutor's week, paced, in under a minute | ## Testing a character - `lint`: contradicting habits; with `--props`, every rule proved or fuzzed; diff --git a/docs/agents.md b/docs/agents.md index 1379629..4a7b47c 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -98,7 +98,9 @@ commands' own documents and return the commands' own JSON, byte for byte: The persona and live tools run in the server's process and keep nothing between calls: pass the returned state back on the next turn and put `stance.line` into the model's prompt (after any cached prefix). A refused or fallback stance is an answer; a bad -persona, state, input or event is a tool error with the `{"error": {"code": "persona", ...}}` object. +persona, state, input or event is a tool error with the `{"error": {"code": "persona", ...}}` object. A person can watch the +individual behind a `strand_path` as it grows: `probbit monitor STRAND --serve --open` (a page on 127.0.0.1) or `--follow` in a +terminal ([persona.md](persona.md) §5.8). `flags` are the command's flags without the dashes: `{"budget_ms": 200, "seed": 3, "summary": true}`; `summary: true` returns the compact answer (README, "First five minutes"). `infeasible` and `refused` / `declined` are answers; bad input diff --git a/docs/persona.md b/docs/persona.md index e0d2ae1..73453d7 100644 --- a/docs/persona.md +++ b/docs/persona.md @@ -19,7 +19,8 @@ This document is normative for the file format, the compilation, the stance docu `probbit persona` (probbit-cli/src/persona.rs and yaml.rs, no dependency) and served on every surface with the same documents: the CLI, `probbit mcp` (tools `probbit_persona_init`, `probbit_persona_turn`), the Python wrapper (`probbit.persona_init`, `persona_turn`, `persona_replay`) and the browser module (probbit-wasm ops 4 and 5). `probbit live` (section 5.7, live.rs) keeps an -individual running on a clock, with learning (section 2.8) and a replayable log. The example personas and their goldens are in +individual running on a clock, with learning (section 2.8) and a replayable log; `probbit monitor` (section 5.8, monitor.rs) +draws its inner state live from that log. The example personas and their goldens are in [examples/persona/](../examples/persona/). An independent reference implementation of this format (Python) gives the same documents byte for byte on every example turn (section 5.3). @@ -687,6 +688,55 @@ Exit codes: `live` 0 when every event got its stance; 2 for a bad persona, state prints one `{"error"}` object on stdout, changes nothing and the run goes on). `verify` 0 when every line replays, 1 at a line that differs, 2 for a bad flag or an unreadable file. +### 5.8 Monitor: watch an individual's inner state + +`probbit monitor STRAND` draws an individual's inner state as live horizontal bars, in the terminal or as a page in the browser. +It replays the strand with the rules of `live verify` (the two share one replay), recomputing every stance document from the +strand alone: it needs no other log, works for any strand on any machine, and says "replay verified" while every line checks, +or names the line that differs. + +```sh +probbit monitor pip.strand --follow # bars in the terminal, redrawn as the strand grows +probbit monitor pip.strand --open # the same board as a page at http://127.0.0.1:PORT/, in the browser +probbit monitor --demo --open # the tutor's week, paced, for someone without a strand yet +``` + +**What it shows.** The latest event. A header: persona and version, seed, the individual's digest, the event number, the hours +since the event before, the replay badge. The stance: one row per trait, its levels as segments sized by their odds, the level +taken in colour and the phrase it says. The moods, with a sparkline of the last 50 events. The senses: the event's inputs as the +turn used them (flags lit, numbers as bars, levels by name, the clock) and the history features against their caps. The habits +in force, the ones that bound in colour, and the violations counter (0 by construction, section 2.5). The learned deltas per +level as centred bars within ±total_cap (with a learning block, section 2.8). The drives, when a document carries `pursue` or +`drives`: documents without them draw no row, so a strand of a later engine lights them up. The stance line, and why. + +**Terminal.** `--once` prints one frame and exits (the default without `--follow`). `--follow` polls the file every 100 ms and +draws appended lines within a second (a line counts once its newline is written; a truncated, removed or rotated strand is +replayed from the start, with a warning); `--fps N` caps the redraws. While a long strand replays from its header (10,000 +events: about 3.5 s on an Apple M4), the board is drawn every 250 ms with a note, at a terminal and on the page. `--plain` +draws plain ASCII; colour follows the rules of the other commands (`NO_COLOR`, `PROBBIT_THEME=plain`, `TERM=dumb`). + +**Browser.** `--serve` follows the strand and binds 127.0.0.1 (std `TcpListener`; port 0, a free one, unless `--port N`) and no +other address: there is no host flag. The URL is the line it prints on stdout; `--open` serves the same and starts the default +browser (`BROWSER` if set, else `open`, `cmd /c start` or `xdg-open`), best effort. It answers GET requests addressed to +`127.0.0.1:PORT` or `localhost:PORT`, and refuses others (403), so a page of another site whose name resolves to 127.0.0.1 +cannot read the documents: + +| route | answer | +|---|---| +| `/` | the page: one file embedded in the binary (about 21 KB, styles and script inline; it fetches no fonts, styles or scripts), with a Content-Security-Policy that lets it connect to its own server and nowhere else | +| `/events` | server-sent events: `meta` (the layout: the strand, the engine, the persona, its traits, moods, inputs and history features in the persona's order), the latest `frame`, then a frame per event: `{n, doc, given, learned, spark, diverges, note}`, that is the stance document, the inputs as the strand logs them, the learned deltas, the moods' recent positions, the line that differs and a warning; a heartbeat comment after 15 quiet seconds; events that land within one poll (100 ms), or while a page falls behind, come as the latest frame | +| `/doc/N` | event N's stance document, canonical JSON and a newline, as `probbit live` printed it (its sha256 is the `stance` digest of the strand's line N + 1); the latest 10,000 are kept | + +The page draws the terminal's board in a dark theme, the bars moving as the odds do (`prefers-reduced-motion` respected), from +360 px wide up. No file is changed, and nothing leaves the machine. + +**Demo.** `probbit monitor --demo` replays the week of `probbit live examples/persona/tutor.yaml --seed 2 --demo week` (section +5.7; the week is written to a temporary file, read back and removed), paced 1 s per hour and each night in 2 s, under a minute: +at a terminal, or with `--serve`, over and over. `--demo --once`, or a pipe, prints its last frame. + +Exit codes: 0 when every line replays, 1 at a line that differs (the frame names it and shows the event before it), 2 for a bad +flag, a port that cannot be had, or a file that cannot be read or is not a strand. + ## 6. What a persona can NOT do - It does not write, read or check text. Text-level rules (banned phrases, exact formats, facts) stay in the prompt or in @@ -725,9 +775,10 @@ store the new state. On `refused` or `fallback`, use the line as given (habits o | `probbit live PERSONA [--seed N \| --state FILE] [--strand FILE] [--events FILE [--watch]] [--clock real\|fixed]` | a resident individual: JSONL events in, one stance per event out, `elapsed_hours` stamped by the clock, the life logged to a strand (section 5.7); `--state` is rewritten after every event | | `probbit live PERSONA --demo week [--seed N] [--strand FILE] [--plain]` | one individual's scripted week on the fixed clock: learning to its cap, a quiet night, habits that hold (section 5.7) | | `probbit live verify STRAND` | replay a strand from its header alone: ok, or the earliest line that differs (exit 1) | +| `probbit monitor STRAND [--follow] [--once] [--plain] [--fps N] [--serve] [--open] [--port N]`, `probbit monitor --demo [...]` | watch an individual's inner state: the strand replayed as live bars in the terminal, or as a page on 127.0.0.1 (section 5.8) | A script is a JSON list of per-turn input objects (or `{"turns": [...]}`; a turn may be `{"inputs": {...}, ...}`). Exit codes -(`live`'s are in section 5.7): +(`live`'s are in section 5.7, `monitor`'s in section 5.8): 0 done (every turn status is an answer), 1 `lint` found an unresolved contradiction, `fuzz` a counterexample or `prove` an unknown rule, 2 a bad persona, state, input, script or rule (ONE `{"error": {"code": "persona", "path", "message"}}` object on stdout) or a bad flag (stderr). The engine's resource controls diff --git a/docs/probbit-monitor.gif b/docs/probbit-monitor.gif new file mode 100644 index 0000000..fba1f04 Binary files /dev/null and b/docs/probbit-monitor.gif differ diff --git a/docs/probbit-monitor.png b/docs/probbit-monitor.png new file mode 100644 index 0000000..81b0c13 Binary files /dev/null and b/docs/probbit-monitor.png differ diff --git a/probbit-cli/src/demo-tutor.yaml b/probbit-cli/src/demo-tutor.yaml new file mode 100644 index 0000000..b669972 --- /dev/null +++ b/probbit-cli/src/demo-tutor.yaml @@ -0,0 +1,176 @@ +# A fictional persona: a playful tutor. probbit persona format 1 (docs/persona.md). +probbit_persona: 1 +identity: + name: Pip + version: 1.1.0 + seed: 1 + summary: a playful, patient tutor; warm, curious, jokes a lot but never at a struggling learner + +traits: + - id: warmth + levels: [cool, neutral, warm] + prior: [0.05, 0.3, 0.65] + spread: 0.5 + say: [plain, "", warm and encouraging] + - id: directness + levels: [gentle, balanced, blunt] + prior: [0.45, 0.45, 0.1] + spread: 0.4 + say: [gentle, "", say plainly what is wrong] + - id: verbosity + levels: [terse, short, full] + prior: [0.1, 0.4, 0.5] + spread: 0.5 + say: [one or two sentences, a short explanation, explain step by step] + - id: humour + levels: [none, light, playful] + prior: [0.15, 0.45, 0.4] + spread: 0.6 + say: [no jokes, a light joke is welcome, playful] + - id: formality + levels: [casual, neutral, formal] + prior: [0.6, 0.35, 0.05] + spread: 0.3 + say: [casual, "", formal] + - id: initiative + levels: [follow, suggest, lead] + prior: [0.2, 0.5, 0.3] + spread: 0.5 + say: [answer only what was asked, suggest what to try next, set a small exercise] + - id: caution + levels: [bold, measured, careful] + prior: [0.25, 0.55, 0.2] + spread: 0.4 + say: [keep it moving, "", double-check every step] + - id: curiosity + levels: [focused, open, exploring] + prior: [0.15, 0.4, 0.45] + spread: 0.5 + say: [stay on this problem, "", ask what they think first] + - id: emoji + levels: [none, sparse, rich] + prior: [0.2, 0.5, 0.3] + spread: 0.5 + say: [no emoji, one emoji at most, emoji welcome] + +moods: + - id: valence + levels: [down, even, up] + prior: [0.1, 0.5, 0.4] + spread: 0.4 + inertia: 0.5 + half_life_hours: 12 + - id: arousal + levels: [calm, steady, keyed] + prior: [0.3, 0.4, 0.3] + spread: 0.3 + inertia: 0.4 + +couplings: + - {vars: [valence, humour], align: 1.0} + - {vars: [valence, emoji], align: 0.6} + - {vars: [arousal, verbosity], align: -0.4} + - {vars: [warmth, formality], align: -0.6} + - {vars: [humour, formality], align: -0.6} + - {vars: [curiosity, verbosity], align: 0.3} + - {vars: [humour, emoji], align: 0.4} + +inputs: + - id: sentiment + kind: level + levels: [negative, neutral, positive] + default: neutral + say: {negative: learner upset, positive: learner upbeat} + effects: + negative: {valence: -1.0, warmth: 0.6, humour: -1.0, directness: -0.6} + positive: {valence: 0.8, humour: 0.5, emoji: 0.3} + - id: confused + kind: flag + say: learner confused + effects: {verbosity: 0.9, humour: -0.6, warmth: 0.4, curiosity: 0.6, initiative: 0.3, directness: -0.3} + - id: error + kind: flag + say: my last answer was wrong + effects: {caution: 1.2, verbosity: -0.3, valence: -0.4, humour: -0.4} + - id: loss + kind: flag + say: learner reports a failure + effects: {valence: -1.6, warmth: 1.0, humour: -2.0, emoji: -0.8} + - id: praise + kind: flag + say: praise + reactivity_spread: 0.4 + effects: {valence: 1.2, humour: 0.5, emoji: 0.5} + - id: criticism + kind: flag + say: criticism + effects: {valence: -0.6, caution: 0.6, humour: -0.4} + - id: stakes + kind: number + say: stakes + effects: {caution: 1.4, humour: -0.8, emoji: -0.6} + - id: time_pressure + kind: number + say: time pressure + effects: {verbosity: -1.8, curiosity: -1.2, humour: -0.4, arousal: 0.6} + - id: claim_done + kind: flag + say: about to say it is solved + effects: {caution: 0.5, "step.check": 0.8} + +history: + - id: errors_in_row + of: error + kind: streak + cap: 3 + say: my errors in a row + effects: {caution: 0.5, humour: -0.4} + - id: confused_recently + of: confused + kind: recent + window: 4 + cap: 3 + say: confusions lately + effects: {verbosity: 0.3, warmth: 0.2} + +habits: + - id: no_jokes_on_loss + when: {loss: true} + then: {humour: [none], emoji: {at_most: sparse}} + say: no jokes, be kind + - id: no_play_when_upset + when: {sentiment: negative} + then: {humour: {at_most: light}} + say: go easy on the jokes # added in 1.1.0: one upset message could make some individuals playful (found by `fuzz`) + - id: confused_no_playful + when: {confused: true} + then: {humour: {at_most: light}, verbosity: {at_least: short}} + say: slow down, no clowning + priority: 10 # wins over after_error_check when a confused learner meets my error (found by `lint`) + - id: after_error_check + when: {error: true} + then: {caution: {at_least: prev+1}, verbosity: {at_most: prev-1}} + say: shorter, double-check + - id: flourish_budget + rules: + linear: [{terms: [[humour, playful, 2], [humour, light, 1], [emoji, rich, 2], [emoji, sparse, 1]], limit: 3}] + say: one flourish at a time + - id: no_formal_emoji + rules: + tables: [{vars: [formality, emoji], forbid: [[formal, rich], [formal, sparse]]}] + say: no emoji when formal + - id: check_last + rules: + precedes: [{before: step.explain, after: step.check}] + say: check understanding after explaining + +agenda: + steps: [encourage, explain, example, check] + prefer: {encourage: 0.5, explain: 0.3, example: -0.1, check: -0.6} + +engine: + on_conflict: yield # two habits that cannot both hold: the lower priority one yields, and the turn says so + +line: + max_tokens: 40 + order: [verbosity, humour, warmth, initiative, curiosity, emoji, directness, caution, formality] diff --git a/probbit-cli/src/live.rs b/probbit-cli/src/live.rs index 12bd9fa..043bd68 100644 --- a/probbit-cli/src/live.rs +++ b/probbit-cli/src/live.rs @@ -61,34 +61,59 @@ impl Live { pub fn head(&self) -> &str { &self.prev } } +/// A strand replayed line by line: `verify` and `probbit monitor` (§5.8) share it, so both read a strand with one set of +/// rules. `open` checks the header and rebuilds the individual; each `step` replays one event line on the fixed clock and +/// checks it (`prev`, `n`, the stance and state digests, the bytes). After an error the replay is spent: the chain past the +/// line that differs cannot be checked. +pub struct Replay { pub live: Live, pub header: Json, pub doc: Json, pub line: usize } +impl Replay { + /// The header line (without its line ending) -> the replay, ready for event lines; Err((1, what differs)) + pub fn open(head: &str) -> Result { + let h = json::parse(head).map_err(|e| (1, format!("the header is not JSON: {}", e.msg)))?; + if h.get("probbit_strand").and_then(Json::as_f64) != Some(FORMAT) { return Err((1, "not a probbit strand (format 1)".into())); } + let doc = h.get("document").cloned().ok_or((1, "the header has no persona document".to_string()))?; + let p = persona::build(&doc).map_err(|e| (1, format!("the persona document: {}: {}", e.path, e.msg)))?; + if h.get("persona").and_then(|x| x.get("digest")).and_then(Json::as_str) != Some(p.digest.as_str()) { return Err((1, "the persona digest does not match the document".into())); } + let st0 = State::read(&p, h.get("state").unwrap_or(&Json::Null)).map_err(|e| (1, format!("the initial state: {}: {}", e.path, e.msg)))?; + 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())); } + Ok(Replay { live, header: h, doc, line: 1 }) + } + /// One event line (without its line ending) -> the stance document it replays to; Err((its 1-based line number, what differs)) + pub fn step(&mut self, l: &str, eng: Engine) -> Result { + 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 is not the sha256 of the line before (a line before it was changed, removed or reordered)".into())); } + if j.get("n").and_then(Json::as_f64) != Some((live.n + 1) as f64) { return Err((i, format!("n is not {}", live.n + 1))); } + let ev = j.get("inputs").ok_or((i, "no inputs".to_string()))?; + let (stance, line) = live.event(ev, eng).map_err(|e| (i, format!("{}: {}", e.path, e.msg)))?; + 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())); } + if line != l { return Err((i, "the line differs".into())); } + self.line = i; + Ok(stance) + } + /// `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); + Json::Obj(vec![("ok".into(), Json::Bool(true)), ("events".into(), Json::Num(live.n as f64)), ("persona".into(), s(&live.p.name)), ("seed".into(), Json::Num(live.st.seed as f64)), + ("engine".into(), self.header.get("engine").cloned().unwrap_or(Json::Null)), ("final_state".into(), s(&live.st.digest)), ("last_line".into(), s(live.head()))]) + } +} +/// A strand's text -> its lines without their line endings (a final newline ends the last line; it does not start another) +pub fn lines(text: &str) -> Vec<&str> { + let mut lines: Vec<&str> = text.split('\n').map(bare).collect(); + if lines.last() == Some(&"") { lines.pop(); } + lines +} /// `probbit live verify`: replay a strand from its header -> Ok(summary) when every line is as recorded; Err((1-based line /// number, what differs)) at the earliest line that is not. Lines end in `\n` or `\r\n`; the chain hashes the line text alone. pub fn verify(text: &str, eng: Engine) -> Result { - let lines: Vec<&str> = text.split('\n').map(bare).collect(); - let lines = if lines.last() == Some(&"") { &lines[..lines.len() - 1] } else { &lines[..] }; - let head = lines.first().copied().unwrap_or(""); - let h = json::parse(head).map_err(|e| (1, format!("the header is not JSON: {}", e.msg)))?; - if h.get("probbit_strand").and_then(Json::as_f64) != Some(FORMAT) { return Err((1, "not a probbit strand (format 1)".into())); } - let doc = h.get("document").ok_or((1, "the header has no persona document".to_string()))?; - let p = persona::build(doc).map_err(|e| (1, format!("the persona document: {}: {}", e.path, e.msg)))?; - if h.get("persona").and_then(|x| x.get("digest")).and_then(Json::as_str) != Some(p.digest.as_str()) { return Err((1, "the persona digest does not match the document".into())); } - let st0 = State::read(&p, h.get("state").unwrap_or(&Json::Null)).map_err(|e| (1, format!("the initial state: {}: {}", e.path, e.msg)))?; - let (mut 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())); } - for (i, l) in lines.iter().enumerate().skip(1) { - let j = json::parse(l).map_err(|e| (i + 1, format!("not JSON: {}", e.msg)))?; - if persona::canon(&j) != *l { return Err((i + 1, "not canonical JSON".into())); } - if j.get("prev").and_then(Json::as_str) != Some(live.head()) { return Err((i + 1, "prev is not the sha256 of the line before (a line before it was changed, removed or reordered)".into())); } - if j.get("n").and_then(Json::as_f64) != Some((live.n + 1) as f64) { return Err((i + 1, format!("n is not {}", live.n + 1))); } - let ev = j.get("inputs").ok_or((i + 1, "no inputs".to_string()))?; - let (stance, line) = live.event(ev, eng).map_err(|e| (i + 1, format!("{}: {}", e.path, e.msg)))?; - if j.get("stance").and_then(Json::as_str) != Some(persona::sha(&stance).as_str()) { return Err((i + 1, "the stance differs".into())); } - if j.get("state").and_then(Json::as_str) != Some(live.st.digest.as_str()) { return Err((i + 1, "the state differs".into())); } - if line != *l { return Err((i + 1, "the line differs".into())); } - } - let s = |x: &str| Json::Str(x.to_string()); - Ok(Json::Obj(vec![("ok".into(), Json::Bool(true)), ("events".into(), Json::Num(live.n as f64)), ("persona".into(), s(&live.p.name)), ("seed".into(), Json::Num(live.st.seed as f64)), - ("engine".into(), h.get("engine").cloned().unwrap_or(Json::Null)), ("final_state".into(), s(&live.st.digest)), ("last_line".into(), s(live.head()))])) + let lines = lines(text); + let mut r = Replay::open(lines.first().copied().unwrap_or(""))?; + for l in lines.iter().skip(1) { r.step(l, eng)?; } + Ok(r.summary()) } /// `verify` as one document: the summary, or {ok: false, line, diverges} pub fn verify_doc(text: &str, eng: Engine) -> Json { diff --git a/probbit-cli/src/main.rs b/probbit-cli/src/main.rs index 6332a5b..e457fb4 100644 --- a/probbit-cli/src/main.rs +++ b/probbit-cli/src/main.rs @@ -21,6 +21,7 @@ mod fuzz; mod json; mod live; mod mcp; +mod monitor; mod persona; mod router; mod run; @@ -690,6 +691,7 @@ fn main() { Some("stats") => stats_cmd(&args), Some("persona") => persona_cmd(&args), Some("live") => live_cmd(&args), + Some("monitor") => monitor::cmd(&args), Some("mcp") => { check_flags(&args, &[], &[]); mcp::serve() } Some("version") | Some("--version") | Some("-V") => { check_flags(&args, &[], &[]); emit(&format!("probbit {VERSION}")) } // `probbit --help` / `-h` asked for the usage: stdout, exit 0 (it went to stderr with exit 2, as for a missing command) @@ -697,4 +699,4 @@ fn main() { _ => { let _ = std::io::stderr().write_all(USAGE.as_bytes()); std::process::exit(2) } } } -const USAGE: &str = "usage: probbit decide [--budget-ms N] [--seed N] [--exact-limit N] [--exact-ms N] [--frontier-states N] [--polish-ms N] [--polish-sweeps N] [--mode auto|exact|sample] [--sweeps N] [--collective on|off] [--cluster on|off] [--cycles on|off] [--chains N] [--threads N] [--cpu-limit PCT] [--mem-limit-mb N] [--priority low|normal] [--max-input-mb N] [--progress [MS]] [--summary] [--top] [--pretty] < problem.json\n probbit demo [--tasks N] [--seed N] [--hard] [--live]\n probbit ir [--max-input-mb N] < problem.json\n probbit run [--op decide|exact|sample] [--budget-ms N] [--deadline-ms N] [--seed N] [--exact-limit N] [--exact-ms N] [--frontier-states N] [--polish-ms N] [--polish-sweeps N] [--sweeps N] [--collective on|off] [--cluster on|off] [--cycles on|off] [--chains N] [--threads N] [--cpu-limit PCT] [--mem-limit-mb N] [--priority low|normal] [--max-input-mb N] [--progress [MS]] [--summary] [--top] [--pretty] < program.json (probbit-ir JSON v1)\n probbit evaluate [the run flags] [--program] < request.json (decision-API adapter: System One request + judge answers + rules)\n probbit stats [--sweeps N] [--pretty] (machine, build, effective controls + source, measured updates/s)\n probbit persona init|turn|replay|explain|diff|lint|fuzz|prove|check|compile|describe PERSONA [flags] (the individuality layer; probbit persona --help)\n probbit live PERSONA [--seed N | --state FILE] [--strand FILE] [--events FILE [--watch]] [--clock real|fixed] (a resident individual: JSONL events in, stances out)\n probbit live PERSONA --demo week [--seed N] [--strand FILE] [--plain] (a scripted week: learning to a cap, a night, rules that hold)\n probbit live verify STRAND (replay a strand; the earliest line that differs)\n probbit mcp (Model Context Protocol server on stdio)\n probbit version\n probbit --help | -h (--plain or NO_COLOR: no colour on a terminal)\n"; +const USAGE: &str = "usage: probbit decide [--budget-ms N] [--seed N] [--exact-limit N] [--exact-ms N] [--frontier-states N] [--polish-ms N] [--polish-sweeps N] [--mode auto|exact|sample] [--sweeps N] [--collective on|off] [--cluster on|off] [--cycles on|off] [--chains N] [--threads N] [--cpu-limit PCT] [--mem-limit-mb N] [--priority low|normal] [--max-input-mb N] [--progress [MS]] [--summary] [--top] [--pretty] < problem.json\n probbit demo [--tasks N] [--seed N] [--hard] [--live]\n probbit ir [--max-input-mb N] < problem.json\n probbit run [--op decide|exact|sample] [--budget-ms N] [--deadline-ms N] [--seed N] [--exact-limit N] [--exact-ms N] [--frontier-states N] [--polish-ms N] [--polish-sweeps N] [--sweeps N] [--collective on|off] [--cluster on|off] [--cycles on|off] [--chains N] [--threads N] [--cpu-limit PCT] [--mem-limit-mb N] [--priority low|normal] [--max-input-mb N] [--progress [MS]] [--summary] [--top] [--pretty] < program.json (probbit-ir JSON v1)\n probbit evaluate [the run flags] [--program] < request.json (decision-API adapter: System One request + judge answers + rules)\n probbit stats [--sweeps N] [--pretty] (machine, build, effective controls + source, measured updates/s)\n probbit persona init|turn|replay|explain|diff|lint|fuzz|prove|check|compile|describe PERSONA [flags] (the individuality layer; probbit persona --help)\n probbit live PERSONA [--seed N | --state FILE] [--strand FILE] [--events FILE [--watch]] [--clock real|fixed] (a resident individual: JSONL events in, stances out)\n probbit live PERSONA --demo week [--seed N] [--strand FILE] [--plain] (a scripted week: learning to a cap, a night, rules that hold)\n probbit live verify STRAND (replay a strand; the earliest line that differs)\n probbit monitor STRAND [--follow] [--once] [--plain] [--fps N] [--serve] [--open] [--port N] | probbit monitor --demo [--open] (watch an individual's inner state live: bars in the terminal or a page on 127.0.0.1, replayed from the strand)\n probbit mcp (Model Context Protocol server on stdio)\n probbit version\n probbit --help | -h (--plain or NO_COLOR: no colour on a terminal)\n"; diff --git a/probbit-cli/src/monitor.html b/probbit-cli/src/monitor.html new file mode 100644 index 0000000..be39f7b --- /dev/null +++ b/probbit-cli/src/monitor.html @@ -0,0 +1,315 @@ + + + + + + +probbit monitor + + + + + +
+
+ +probbit monitor + + + +connecting +
+ +

waiting for the strand

+
+ + +
+ + + + + +
+
+
+ + + diff --git a/probbit-cli/src/monitor.rs b/probbit-cli/src/monitor.rs new file mode 100644 index 0000000..7df463a --- /dev/null +++ b/probbit-cli/src/monitor.rs @@ -0,0 +1,873 @@ +//! `probbit monitor STRAND` (docs/persona.md §5.8): watch an individual's inner state as live horizontal bars. The monitor +//! replays the strand with `live::Replay`, the rules `probbit live verify` uses, so it recomputes every stance document from +//! the strand alone (no other log, any strand, any machine) and says whether the replay checks. It draws the latest event: the +//! stance (each trait's levels as segments with their exact odds, the level taken highlighted, the phrase it says), the moods +//! with a sparkline of the last 50 events, the senses (the event's inputs and the history features), the habits in force and +//! the ones that bound, the learned deltas, the drives when the documents carry them (feature-detected), and the stance line. +//! `--serve` draws the same in a browser: one page embedded in the binary, fed by server-sent events from a server on +//! 127.0.0.1. It changes no file (the demo's week goes through a temporary one, removed at once) and sends nothing anywhere. +use crate::json::{self, Json}; +use crate::live::Replay; +use crate::persona::{self, Engine}; +use crate::theme::{self, Col, Theme, CYAN, DIM, GREEN, GREY, MAGENTA, YELLOW}; +use std::collections::VecDeque; +use std::io::{Read, Seek, Write}; +use std::net::{Ipv4Addr, TcpListener, TcpStream}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::Duration; + +/// Moods (warm amber), wanting (orange), a line that diverges or a prediction error below zero (red), the shades of the levels +/// not taken +const AMBER: Col = Col(214, 33); +const ORANGE: Col = Col(208, 33); +const RED: Col = Col(196, 91); +const SHADE: [Col; 2] = [Col(244, 37), Col(239, 90)]; +/// Events a mood's sparkline covers +const SPARK: usize = 50; +/// The tutor persona `--demo` plays (a copy of examples/persona/tutor.yaml; a test keeps them equal) +const TUTOR: &str = include_str!("demo-tutor.yaml"); +/// Cells of a level bar and of a number bar +const BAR: usize = 20; +const NBAR: usize = 8; +/// The page `--serve` serves at `/`: one file, its styles and script inline; it fetches nothing but `/events` from its own server +const PAGE: &str = include_str!("monitor.html"); +/// Seconds between heartbeats on a quiet event stream +const BEAT: u64 = 15; +/// Stance documents `/doc/N` keeps (the latest; about 2.5 KB each) +const KEEP: usize = 10_000; +/// Connections served at once (more are refused with 503) +const CONNS: usize = 64; + +const HELP: &str = "usage: probbit monitor STRAND [--follow] [--once] [--plain] [--fps N] [--serve] [--open] [--port N] | probbit monitor --demo [--once] [--plain] [--serve] [--open] [--port N]\n Watch an individual's inner state (docs/persona.md §5.8): replays the strand (`probbit live --strand` writes it) with the\n rules of `probbit live verify`, recomputing every stance document from the strand alone, and draws the latest event as\n horizontal bars: the stance (each trait's levels with their exact odds, the level taken highlighted, the phrase it says), the\n moods with a sparkline of the last 50 events, the senses (the event's inputs, the history features), the habits in force and\n the ones that bound, the learned deltas, the drives when the documents carry them, and the stance line. In the terminal, or\n with --serve as a page in the browser. It changes no file (--demo: its week goes through a temporary one, removed at\n once) and sends nothing anywhere.\nflags:\n --follow keep watching: lines appended to the strand are replayed and drawn within a second (a line counts once its\n newline is written); a truncated or rotated strand is replayed from the start, with a warning; Ctrl-C quits\n --once one frame on stdout, then exit (the default without --follow)\n --plain plain ASCII, no colour (NO_COLOR=1: no colour; PROBBIT_THEME=plain or TERM=dumb: as --plain)\n --fps N with --follow, at most N redraws per second (1-60, default 10)\n --serve the page instead of the terminal, following the strand (or playing the demo week, over and over): its URL\n http://127.0.0.1:PORT/ is the line on stdout. GET / the page (one file; it fetches no fonts, styles or\n scripts), /events its server-sent events (the layout, the latest frame, then a frame per event, a heartbeat\n every 15 s; events that land within one 100 ms poll, or while a page falls behind, come as the latest\n frame), /doc/N event N's stance document, as `probbit live` printed it (the latest 10000 are kept). It\n binds 127.0.0.1 and no other address (there is no --host), answers requests addressed to 127.0.0.1 or\n localhost, and sends nothing anywhere; Ctrl-C quits\n --open serve the page (as --serve does) and open it in the default browser (BROWSER if set; else open on macOS,\n start on Windows, xdg-open elsewhere); a browser that does not start fails nothing\n --port N with --serve or --open, the port (default 0: a free one)\n --demo the tutor's scripted week (as `probbit live examples/persona/tutor.yaml --seed 2 --demo week`), replayed in\n memory: at a terminal or with --serve paced 1 s per hour, each night in 2 s (under a minute); otherwise, or\n with --once, its last frame\nexit: 0 every line replays, 1 a line differs (the frame names it and shows the event before it), 2 a bad flag, a port that\n cannot be had, or a file that cannot be read or is not a strand.\n"; + +fn strs(j: Option<&Json>) -> Vec { j.and_then(Json::as_arr).map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default() } + +/// A trait or a mood: its id, levels and per-level phrases, in the persona's order +struct Var { id: String, levels: Vec, say: Vec } +/// A declared input: its id, kind (flag | level | number) and, for a number, its max +struct Input { id: String, kind: String, max: f64 } +/// What the layout reads from the strand's header +struct Meta { name: String, version: String, seed: u64, individual: String, engine: String, traits: Vec, moods: Vec, inputs: Vec, + history: Vec<(String, f64)>, learning: Option<(Vec, f64)>, caps: (f64, f64), goals: Vec } +impl Meta { + fn of(r: &Replay) -> Meta { + let d = persona::describe(&r.live.p); + let obj = |k: &str| d.get(k).and_then(Json::as_obj).map(|o| o.to_vec()).unwrap_or_default(); + let decl = |k: &str| r.doc.get(k).and_then(Json::as_arr).map(|a| a.to_vec()).unwrap_or_default(); + let max = |id: &str| decl("inputs").iter().find(|i| i.get("id").and_then(Json::as_str) == Some(id)).and_then(|i| i.get("max")).and_then(Json::as_f64).unwrap_or(1.0); + let cap = |b: &str, d: f64| r.doc.get("drives").and_then(|x| x.get(b)).and_then(|x| x.get("cap")).and_then(Json::as_f64).unwrap_or(d); + Meta { name: r.live.p.name.clone(), version: r.live.p.version.clone(), seed: r.live.st.seed, + individual: r.header.get("state").and_then(|s| s.get("digest")).and_then(Json::as_str).unwrap_or("").to_string(), + engine: r.header.get("engine").and_then(Json::as_str).unwrap_or("").to_string(), + traits: obj("traits").into_iter().map(|(id, t)| Var { levels: strs(t.get("levels")), say: strs(t.get("say")), id }).collect(), + moods: obj("moods").into_iter().map(|(id, l)| Var { levels: strs(Some(&l)), say: vec![], id }).collect(), + inputs: obj("inputs").into_iter().map(|(id, x)| Input { kind: x.get("kind").and_then(Json::as_str).unwrap_or("").to_string(), max: max(&id), id }).collect(), + history: decl("history").iter().filter_map(|h| Some((h.get("id")?.as_str()?.to_string(), h.get("cap").and_then(Json::as_f64).unwrap_or(5.0)))).collect(), + learning: d.get("learning").map(|l| (strs(l.get("traits")), l.get("total_cap").and_then(Json::as_f64).unwrap_or(1.0))), + caps: (cap("wanting", 3.0), cap("afterglow", 2.0)), + // a drives block's goals, in the order of the levels of `pursue` (describe hides that trait; its learned row needs them) + goals: strs(d.get("drives").and_then(|x| x.get("goals"))) } + } +} + +/// One replayed event: its number, its stance document, its inputs as the strand logs them, the learned deltas after it +struct Frame { n: u64, doc: Json, given: Json, learned: Json } +/// A strand being watched: the replay, the layout, the latest frame, the moods' recent positions, the line that diverged (if any) +struct Watch { rep: Replay, meta: Meta, last: Option, spark: Vec>, bad: Option<(usize, String)> } +impl Watch { + fn open(head: &str) -> Result { + let rep = Replay::open(head)?; let meta = Meta::of(&rep); let n = meta.moods.len(); + Ok(Watch { rep, meta, last: None, spark: vec![VecDeque::new(); n], bad: None }) + } + /// Replay one event line; after a line that differs, nothing more is replayed + fn feed(&mut self, l: &str, eng: Engine) { + if self.bad.is_some() { return; } + match self.rep.step(l, eng) { + Ok(doc) => { + let given = json::parse(l).ok().and_then(|j| j.get("inputs").cloned()).unwrap_or(Json::Null); + let learned = self.rep.live.st.to_json(&self.rep.live.p).get("learned").cloned().unwrap_or(Json::Null); + for (m, s) in self.meta.moods.iter().zip(self.spark.iter_mut()) { if s.len() == SPARK { s.pop_front(); } s.push_back(position(&odds(&doc, "mood", m))); } + self.last = Some(Frame { n: self.rep.live.n, doc, given, learned }); + } + Err(e) => self.bad = Some(e) } + } +} +/// A variable's odds in its levels' order (0 for a level the document does not list) +fn odds(doc: &Json, group: &str, v: &Var) -> Vec { + let o = doc.get(group).and_then(|g| g.get(&v.id)).and_then(|x| x.get("odds")); + v.levels.iter().map(|l| o.and_then(|o| o.get(l)).and_then(Json::as_f64).unwrap_or(0.0)).collect() +} +/// Where the odds sit along the levels, 0 (the lowest level) to 1 (the highest): the mean level index over (levels - 1) +fn position(ps: &[f64]) -> f64 { + let (t, k) = (ps.iter().sum::(), ps.len()); + if k < 2 || t <= 0.0 { return 0.0; } + ps.iter().enumerate().map(|(i, p)| p * i as f64).sum::() / t / (k - 1) as f64 +} + +/// How a frame is drawn: the theme (None: no colour), plain ASCII, the columns a line may take +struct Style { th: Option, ascii: bool, cols: usize } +impl Style { + fn paint(&self, c: Col, s: &str) -> String { self.th.map_or_else(|| s.to_string(), |t| t.paint(c, s)) } + fn bold(&self, c: Col, s: &str) -> String { self.th.map_or_else(|| s.to_string(), |t| t.bold(c, s)) } + fn g<'a>(&self, uni: &'a str, ascii: &'a str) -> &'a str { if self.ascii { ascii } else { uni } } +} +/// Odds -> cells per level summing to `w` (largest remainder; ties to the earlier level) +fn cells(ps: &[f64], w: usize) -> Vec { + let t = ps.iter().map(|p| p.max(0.0)).sum::(); + if t <= 0.0 { return vec![0; ps.len()]; } + let raw: Vec = ps.iter().map(|p| p.max(0.0) / t * w as f64).collect(); + let mut c: Vec = raw.iter().map(|x| x.floor() as usize).collect(); + let mut order: Vec = (0..ps.len()).collect(); + order.sort_by(|&a, &b| (raw[b] - raw[b].floor()).total_cmp(&(raw[a] - raw[a].floor())).then(a.cmp(&b))); + let left = w.saturating_sub(c.iter().sum()); + for &i in order.iter().take(left) { c[i] += 1; } + c +} +/// A stacked bar: one segment per level, the level taken (`pick`) in `hue`, the others in alternating shades +fn stack(s: &Style, ps: &[f64], pick: Option, hue: Col) -> String { + cells(ps, BAR).iter().enumerate().filter(|(_, &n)| n > 0).map(|(i, &n)| { + if Some(i) == pick { s.paint(hue, &s.g("█", "#").repeat(n)) } + else { let glyph = if s.th.is_some() { s.g("█", "#") } else if i % 2 == 0 { s.g("▒", "=") } else { s.g("░", "-") }; s.paint(SHADE[i % 2], &glyph.repeat(n)) } }).collect() +} +/// `level 0.37` per level, the level taken in brackets +fn labels(s: &Style, levels: &[String], ps: &[f64], pick: Option, hue: Col) -> String { + levels.iter().zip(ps).enumerate().map(|(i, (l, p))| if Some(i) == pick { s.bold(hue, &format!("[{l} {p:.2}]")) } else { s.paint(GREY, &format!(" {l} {p:.2} ")) }).collect::>().join(" ") +} +/// A number bar: `v / max` of `NBAR` cells +fn nbar(s: &Style, v: f64, max: f64, hue: Col) -> String { + let k = ((v / max.max(1e-12)).clamp(0.0, 1.0) * NBAR as f64).round() as usize; + format!("{}{}{}{}", s.paint(DIM, s.g("▕", "[")), s.paint(hue, &s.g("█", "#").repeat(k)), s.paint(DIM, &s.g("░", ".").repeat(NBAR - k)), s.paint(DIM, s.g("▏", "]"))) +} +/// A centred bar for a signed value within ±cap: `h` cells each side of the centre mark +fn centred(s: &Style, v: f64, cap: f64, h: usize) -> String { + let k = ((v.abs() / cap.max(1e-12)).clamp(0.0, 1.0) * h as f64).round() as usize; + let (fill, mid) = (s.g("━", if v < 0.0 { "-" } else { "+" }), s.g("┃", "|")); + let hue = if v < 0.0 { MAGENTA } else { GREEN }; + if v < 0.0 { format!("{}{}{}{}", " ".repeat(h - k), s.paint(hue, &fill.repeat(k)), s.paint(DIM, mid), " ".repeat(h)) } + else { format!("{}{}{}{}", " ".repeat(h), s.paint(DIM, mid), s.paint(hue, &fill.repeat(k)), " ".repeat(h - k)) } +} +/// The moods' recent positions as a sparkline +fn spark(s: &Style, xs: &VecDeque) -> String { + let ramp: Vec = s.g("▁▂▃▄▅▆▇█", "_.:-=+*#").chars().collect(); + xs.iter().map(|x| ramp[(x.clamp(0.0, 1.0) * (ramp.len() - 1) as f64).round() as usize]).collect() +} +/// Hours as a short clock: seconds, minutes or hours +fn hours(h: f64) -> String { if h < 1.0 / 60.0 { format!("+{:.0} s", h * 3600.0) } else if h < 1.0 { format!("+{:.1} min", h * 60.0) } else { format!("+{h:.2} h") } } +/// A count or a cap: whole numbers without decimals +fn whole(x: f64) -> String { if x.fract() == 0.0 && x.abs() < 1e15 { format!("{}", x as i64) } else { format!("{x}") } } +/// A digest's leading 8 hex digits +fn short(d: &str) -> &str { let h = d.strip_prefix("sha256:").unwrap_or(d); &h[..h.len().min(8)] } + +/// 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 { + let dot = s.paint(DIM, s.g("·", "|")); + let lab = |t: &str| s.bold(MAGENTA, &format!("{t:<8}")); + let mut out = vec![]; + let bad = bad_head.or_else(|| w.and_then(|w| w.bad.as_ref())); + let badge = match bad { Some((n, why)) => s.bold(RED, &format!("line {n} diverges: {why}")), None => s.bold(GREEN, s.g("replay verified ✓", "replay verified [ok]")) }; + let Some(w) = w else { + 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; + }; + let m = &w.meta; + let f = w.last.as_ref(); + let mut head = format!("{} {} {dot} seed {} {dot} individual {} {dot} event {}", s.bold(CYAN, "probbit monitor"), s.bold(CYAN, &format!("{} {}", m.name, m.version)), m.seed, short(&m.individual), f.map_or(0, |f| f.n)); + if let Some(h) = f.and_then(|f| f.given.get("elapsed_hours")).and_then(Json::as_f64) { head += &format!(" {dot} {} since the event before", hours(h)); } + out.push(format!("{head} {dot} {badge}")); + if let Some(t) = note { out.push(s.paint(YELLOW, t)); } + 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; + }; + 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); + let status = doc.get("status").and_then(Json::as_str).unwrap_or(""); + // 1. the stance: one row per trait + let mut top = true; + for v in &m.traits { + let Some(t) = doc.get("stance").and_then(|x| x.get(&v.id)) else { continue }; + let ps = odds(doc, "stance", v); + let level = t.get("level").and_then(Json::as_str).unwrap_or(""); + let released = t.get("released") != Some(&Json::Bool(false)); + let pick = v.levels.iter().position(|l| l == level).filter(|_| released); + let say = pick.and_then(|i| v.say.get(i)).filter(|x| !x.is_empty()).cloned().unwrap_or_default(); + let note = if released { say } else { s.paint(GREY, &format!("({level}: not released)")) }; + out.push(format!("{}{: { let on = v == Some(&Json::Bool(true)); chips.push(if on { s.bold(CYAN, &format!("{} {}", s.g("●", "(x)"), x.id)) } else { s.paint(GREY, &format!("{} {}", s.g("○", "( )"), x.id)) }); } + ("number", v) => { let n = v.and_then(Json::as_f64).unwrap_or(0.0); nums.push(format!("{} {} {n:.2}", x.id, nbar(s, n, x.max, CYAN))); } + (_, v) => { let l = v.and_then(Json::as_str).unwrap_or(""); chips.push(format!("{} {}", s.paint(GREY, &x.id), s.bold(CYAN, l))); } } + } + if let Some(g) = val("goals").filter(|g| !g.is_null()) { chips.push(format!("{} {}", s.paint(GREY, "goals"), persona::canon(g))); } + out.push(format!("{}{}", lab("SENSES"), chips.join(" "))); + if let Some(h) = f.given.get("elapsed_hours").and_then(Json::as_f64) { nums.push(format!("clock {}", hours(h))); } + if !nums.is_empty() { out.push(format!("{}{}", lab(""), nums.join(" "))); } + let hist: Vec = m.history.iter().map(|(id, cap)| { let n = val(id).and_then(Json::as_f64).unwrap_or(0.0); format!("{id} {}/{}", whole(n), whole(*cap)) }).collect(); + if !hist.is_empty() { out.push(format!("{}{} {}", lab(""), s.paint(GREY, "history"), hist.join(" "))); } + if let Some(ig) = doc.get("ignored").and_then(Json::as_arr).filter(|a| !a.is_empty()) { out.push(format!("{}{} {}", lab(""), s.paint(GREY, "ignored"), strs(Some(&Json::Arr(ig.to_vec()))).join(" "))); } + // 4. the habits: in force, the ones that bound highlighted, the violations counter (0 by construction) + let hb = doc.get("habits"); + let list = |k: &str| strs(hb.and_then(|h| h.get(k))); + let bound = list("bound"); + let active: Vec = list("active").iter().map(|h| if bound.contains(h) { s.bold(CYAN, &format!("[{h}]")) } else { s.paint(GREY, h) }).collect(); + let viol = hb.and_then(|h| h.get("violations")).and_then(Json::as_f64).unwrap_or(0.0); + let mut hl = format!("{}{} {dot} violations {}", lab("HABITS"), if active.is_empty() { s.paint(GREY, "none in force") } else { active.join(" ") }, s.bold(if viol == 0.0 { GREEN } else { RED }, &whole(viol))); + for k in ["yielded", "conflict"] { let l = list(k); if !l.is_empty() { hl += &format!(" {dot} {k} {}", l.join(" ")); } } + out.push(hl); + // 5. the learned deltas, centred within ±total_cap + if let Some((traits, cap)) = &m.learning { + for (k, t) in traits.iter().enumerate() { + let levels = m.traits.iter().find(|v| &v.id == t).map(|v| v.levels.clone()).or_else(|| (t == "pursue").then(|| m.goals.clone())).unwrap_or_default(); + let ds: Vec = f.learned.get(t).and_then(Json::as_arr).map(|a| a.iter().filter_map(Json::as_f64).collect()).unwrap_or_default(); + let cellsx: Vec = levels.iter().zip(&ds).map(|(l, d)| format!("{l} {} {d:+.2}", centred(s, *d, *cap, 5))).collect(); + out.push(format!("{}{t: String { + let dot = s.g("·", "|"); + let by = if engine.is_empty() { String::new() } else { format!(" {dot} written by {engine}") }; + s.paint(GREY, &if path.is_empty() { format!("strand: the demo week, in memory{by} {dot} the same week: probbit live examples/persona/tutor.yaml --seed 2 --demo week") } + else { format!("strand {path}{by} {dot} replay: probbit live verify {path}") }) +} +/// Per goal: a drive's values, from an object keyed by goal or a list in the goals' order +fn per_goal(v: Option<&Json>, goals: &[String]) -> Vec> { + match v { Some(Json::Obj(o)) => goals.iter().map(|g| o.iter().find(|(k, _)| k == g).and_then(|(_, x)| x.as_f64())).collect(), + Some(Json::Arr(a)) => (0..goals.len()).map(|i| a.get(i).and_then(Json::as_f64)).collect(), _ => vec![None; goals.len()] } +} +/// The drives rows (0.8.0 documents with a drives block): `pursue` as a stance row, then per goal wanting, afterglow and the +/// last prediction error +fn drives(doc: &Json, m: &Meta, s: &Style, idw: usize, lab: &dyn Fn(&str) -> String) -> Vec { + let (pz, dv) = (doc.get("pursue").filter(|x| x.as_obj().is_some()), doc.get("drives").filter(|x| x.as_obj().is_some())); + if pz.is_none() && dv.is_none() { return vec![]; } + let mut out = vec![]; + // the goals: pursue's odds (canonical order), else the goals a drive object names + let mut goals: Vec = pz.and_then(|p| p.get("odds")).and_then(Json::as_obj).map(|o| o.iter().map(|(k, _)| k.clone()).collect()).unwrap_or_default(); + if goals.is_empty() { goals = dv.and_then(Json::as_obj).and_then(|o| o.iter().find_map(|(_, v)| v.as_obj())).map(|o| o.iter().map(|(k, _)| k.clone()).collect()).unwrap_or_default(); } + if let Some(p) = pz { + let v = Var { id: "pursue".into(), levels: goals.clone(), say: vec![] }; + let ps: Vec = goals.iter().map(|g| p.get("odds").and_then(|o| o.get(g)).and_then(Json::as_f64).unwrap_or(0.0)).collect(); + let pick = p.get("goal").and_then(Json::as_str).and_then(|g| goals.iter().position(|x| x == g)).filter(|_| p.get("released") != Some(&Json::Bool(false))); + let say = p.get("say").and_then(Json::as_str).unwrap_or(""); + out.push(format!("{}{:, path: &str) -> Json { + let s = |x: &str| Json::Str(x.to_string()); + let strs = |v: &[String]| Json::Arr(v.iter().map(|x| s(x)).collect()); + let o = |kv: Vec<(&str, Json)>| json::obj(kv); + let mut kv = vec![("path", s(path)), ("demo", Json::Bool(path.is_empty()))]; + if let Some(m) = w.map(|w| &w.meta) { + let vars = |vs: &[Var]| Json::Arr(vs.iter().map(|v| o(vec![("id", s(&v.id)), ("levels", strs(&v.levels)), ("say", strs(&v.say))])).collect()); + kv.extend([("engine", s(&m.engine)), ("persona", o(vec![("name", s(&m.name)), ("version", s(&m.version)), ("seed", Json::Num(m.seed as f64)), ("individual", s(&m.individual))])), + ("traits", vars(&m.traits)), ("moods", vars(&m.moods)), + ("inputs", Json::Arr(m.inputs.iter().map(|x| o(vec![("id", s(&x.id)), ("kind", s(&x.kind)), ("max", Json::Num(x.max))])).collect())), + ("history", Json::Arr(m.history.iter().map(|(id, cap)| o(vec![("id", s(id)), ("cap", Json::Num(*cap))])).collect())), + ("learning", m.learning.as_ref().map_or(Json::Null, |(t, cap)| o(vec![("traits", strs(t)), ("total_cap", Json::Num(*cap))]))), + ("caps", o(vec![("wanting", Json::Num(m.caps.0)), ("afterglow", Json::Num(m.caps.1))])), ("goals", strs(&m.goals))]); + } + o(kv) +} +/// One frame for the page (`event: frame`): the latest event (its number, stance document, inputs as the strand logs them, +/// learned deltas after it), the moods' recent positions, the line that diverges and the note, if any +fn frame_json(w: Option<&Watch>, bad_head: Option<&(usize, String)>, note: Option<&str>) -> Json { + let f = w.and_then(|w| w.last.as_ref()); + let bad = bad_head.or_else(|| w.and_then(|w| w.bad.as_ref())); + let spark = w.map_or(Json::Obj(vec![]), |w| Json::Obj(w.meta.moods.iter().zip(&w.spark).map(|(m, xs)| (m.id.clone(), Json::Arr(xs.iter().map(|x| Json::Num(persona::r6(*x))).collect()))).collect())); + let of = |g: fn(&Frame) -> &Json| f.map_or(Json::Null, |f| g(f).clone()); + json::obj(vec![("n", Json::Num(f.map_or(0, |f| f.n) as f64)), ("doc", of(|f| &f.doc)), ("given", of(|f| &f.given)), ("learned", of(|f| &f.learned)), ("spark", spark), + ("diverges", bad.map_or(Json::Null, |(l, why)| json::obj(vec![("line", Json::Num(*l as f64)), ("why", json::str(why))]))), ("note", note.map_or(Json::Null, json::str))]) +} + +/// A strand file read as it grows: whole lines (a line counts once its newline is written). It starts over when the +/// file shrinks, disappears, or gets another header (truncated or rotated). +struct Tail { path: String, at: u64, part: Vec, head: Option>, gone: bool, seen: Option<(u64, Option)> } +enum Got { Lines(Vec), Restart(&'static str), Nothing } +impl Tail { + fn new(path: &str) -> Tail { Tail { path: path.to_string(), at: 0, part: vec![], head: None, gone: false, seen: None } } + fn reset(&mut self) { self.at = 0; self.part.clear(); self.head = None; self.seen = None; } + fn poll(&mut self) -> Got { + let Ok(md) = std::fs::metadata(&self.path) else { + if self.gone { return Got::Nothing; } self.gone = true; self.reset(); return Got::Restart("the strand is gone (removed or rotated): waiting for it"); + }; + if std::mem::replace(&mut self.gone, false) { self.reset(); return Got::Restart("the strand is back: replayed from the start"); } + let (len, mtime) = (md.len(), md.modified().ok()); + if self.seen == Some((len, mtime)) { return Got::Nothing; } + let shrank = len < self.at + self.part.len() as u64; + let mut f = match std::fs::File::open(&self.path) { Ok(f) => f, Err(_) => return Got::Nothing }; + // another header at the top: the file was replaced (rotated) by one at least as long + let swapped = self.head.as_ref().is_some_and(|h| { let mut b = vec![0u8; h.len()]; f.read_exact(&mut b).is_err() || &b != h }); + if shrank || swapped { self.reset(); return Got::Restart(if shrank { "the strand shrank (truncated or rotated): replayed from the start" } else { "the strand has another header (rotated): replayed from the start" }); } + self.seen = Some((len, mtime)); + let mut buf = vec![]; + if f.seek(std::io::SeekFrom::Start(self.at + self.part.len() as u64)).and_then(|_| f.read_to_end(&mut buf)).is_err() { return Got::Nothing; } + self.part.extend_from_slice(&buf); + let Some(end) = self.part.iter().rposition(|&b| b == b'\n') else { return Got::Nothing }; + let done: Vec = self.part.drain(..=end).collect(); + self.at += done.len() as u64; + if self.head.is_none() { self.head = done.iter().position(|&b| b == b'\n').map(|e| done[..e].to_vec()); } + Got::Lines(crate::live::lines(&String::from_utf8_lossy(&done)).into_iter().map(String::from).collect()) + } +} +/// A strand followed as it grows (`--follow`, `--serve`): the file, the replay (None until a header is in, or when it does not +/// replay), the header's error, the warning to show +struct Followed { tail: Tail, w: Option, bad_head: Option<(usize, String)>, note: Option<&'static str> } +impl Followed { + fn new(path: &str) -> Followed { Followed { tail: Tail::new(path), w: None, bad_head: None, note: None } } + /// Replay what was appended since the last poll -> None: nothing new; Some(true): the strand starts over (a truncated or + /// rotated file); Some(false): lines replayed (`each` sees the replay after every event it adds) + fn poll(&mut self, eng: Engine, each: &mut dyn FnMut(&Watch)) -> Option { + match self.tail.poll() { + Got::Lines(ls) => { for l in ls { if let Some(x) = &mut self.w { let n = x.rep.live.n; x.feed(&l, eng); if x.rep.live.n != n && x.bad.is_none() { each(x); } continue; } + if self.bad_head.is_none() { match Watch::open(&l) { Ok(x) => self.w = Some(x), Err(e) => self.bad_head = Some(e) } } } + Some(false) } + Got::Restart(why) => { self.w = None; self.bad_head = None; self.note = Some(why); Some(true) } + Got::Nothing => None } + } +} + +/// Write to stdout -> false once the reader has gone +fn out(s: &str) -> bool { let mut o = std::io::stdout().lock(); o.write_all(s.as_bytes()).and_then(|_| o.flush()).is_ok() } +/// Plain ASCII: --plain, PROBBIT_THEME set to anything but neon, TERM=dumb +fn ascii(args: &[String]) -> bool { + args.iter().any(|a| a == "--plain") || std::env::var("PROBBIT_THEME").is_ok_and(|v| !v.is_empty() && v != "neon") || std::env::var("TERM").is_ok_and(|t| t == "dumb") +} + +/// `probbit monitor STRAND [--follow] [--once] [--plain] [--fps N]` +pub fn cmd(args: &[String]) { + if args.iter().any(|a| a == "--help" || a == "-h") { crate::emit_raw(HELP); return; } + if args.iter().any(|a| ["--host", "--bind", "--address"].iter().any(|f| a == f || a.starts_with(&format!("{f}=")))) { crate::fail("monitor --serve binds 127.0.0.1 and no other address, so the page stays on this machine: there is no --host") } + let path = args.get(1).filter(|a| !a.starts_with("--")).cloned(); + let mut a = vec!["monitor".to_string()]; a.extend(args.iter().skip(if path.is_some() { 2 } else { 1 }).cloned()); + crate::check_flags(&a, &["--fps", "--port"], &["--follow", "--once", "--plain", "--demo", "--serve", "--open"]); + let has = |f: &str| args.iter().any(|a| a == f); + // --open opens the page, so it serves it: `--open` is `--serve --open` + let serve_ = has("--serve") || has("--open"); + let sv = if has("--serve") { "--serve" } else { "--open" }; + if has("--follow") && has("--once") { crate::fail("monitor: --once draws one frame and --follow keeps drawing: give one of them") } + if serve_ && has("--once") { crate::fail(&format!("monitor: --once draws one frame and {sv} keeps serving: give one of them")) } + if let Some(f) = ["--fps", "--plain"].iter().find(|f| serve_ && has(f)) { crate::fail(&format!("monitor {sv}: {f} does not apply (the page draws itself)")) } + if !serve_ && has("--port") { crate::fail("monitor: --port goes with --serve (or --open)") } + let fps: u32 = crate::arg(args, "--fps", 10); if !(1..=60).contains(&fps) { crate::fail("monitor: --fps N, from 1 to 60") } + let port: u16 = crate::arg(args, "--port", 0); + let engine = crate::persona_engine(); let eng: Engine = &engine; + let tty = theme::stdout_is_terminal(); + let s = Style { th: theme::stdout(args), ascii: ascii(args), cols: if tty { theme::size(1).0.max(20) } else { usize::MAX } }; + if has("--demo") { + if path.is_some() { crate::fail("monitor --demo plays its own week: give no STRAND") } + if has("--follow") { crate::fail("monitor --demo: --follow does not apply (the demo is its own week)") } + if serve_ { serve(port, has("--open"), &mut |hub| feed_demo(hub, eng)) } + return demo(&s, has("--once"), eng); + } + let Some(path) = path else { crate::fail("monitor: the strand file comes before the flags: probbit monitor STRAND [flags] (or probbit monitor --demo)") }; + let bytes = std::fs::read(&path).unwrap_or_else(|e| crate::fail(&format!("monitor: cannot read {path}: {e}"))); + let text = String::from_utf8_lossy(&bytes); + // whole lines: a last line without its newline is still being written + let (done, rest) = match text.rfind('\n') { Some(i) => (&text[..=i], &text[i + 1..]), None => ("", &text[..]) }; + let lines = crate::live::lines(done); + let strand = |h: &str| json::parse(h).ok().and_then(|h| h.get("probbit_strand").and_then(Json::as_f64)) == Some(1.0); + if let Some(h) = lines.first() { if !strand(h) { crate::fail(&format!("monitor: {path} is not a probbit strand (format 1)")) } } + // --follow and --serve wait for a header still to be written; one frame needs it now + if serve_ { serve(port, has("--open"), &mut |hub| feed_strand(&path, hub, eng)) } + if has("--follow") { follow(&path, &s, fps, eng) } + let head = lines.first().copied().unwrap_or(rest); + if head.is_empty() { crate::fail(&format!("monitor: {path} is empty: not a strand")) } + if !strand(head) { crate::fail(&format!("monitor: {path} is not a probbit strand (format 1)")) } + let note = (!rest.is_empty() && !lines.is_empty()).then(|| format!("line {} is incomplete (no newline at its end): not replayed", lines.len() + 1)); + let (w, bad_head) = match Watch::open(head) { Ok(mut w) => { for l in lines.iter().skip(1) { w.feed(l, eng); } (Some(w), None) } Err(e) => (None, Some(e)) }; + let fr = frame(w.as_ref(), bad_head.as_ref(), &path, note.as_deref(), &s); + out(&fr.iter().map(|l| crate::tui::clip(l, s.cols) + "\n").collect::()); + if bad_head.is_some() || w.is_some_and(|w| w.bad.is_some()) { std::process::exit(1) } +} + +/// Draw a frame in place at a terminal of `size` (columns, rows) -> false once the reader has gone. A frame taller than the +/// terminal would scroll on every redraw: the rows that fit are kept. +fn redraw(mut fr: Vec, size: (usize, usize)) -> bool { + fr.truncate(size.1.saturating_sub(1).max(1)); + out(&format!("\x1b[H{}\x1b[J", fr.iter().map(|l| crate::tui::clip(l, size.0.max(20)) + "\x1b[K\n").collect::())) +} + +/// The demo's week: the tutor's scripted week (`live::week`, seed 2) written to a temporary strand, read back and removed -> its +/// lines +fn week(eng: Engine) -> Vec { + let doc = persona::parse_doc(TUTOR, false).unwrap_or_else(|e| crate::fail(&format!("monitor --demo: the tutor: {e}"))); + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.subsec_nanos()); + let f = std::env::temp_dir().join(format!("probbit-monitor-demo-{}-{nanos}.strand", std::process::id())); + let fp = f.to_string_lossy().to_string(); + let made = crate::live::week(&doc, Some(2), Some(&fp), None, &mut |_: &str| {}, eng); + let text = std::fs::read_to_string(&f); let _ = std::fs::remove_file(&f); + if let Err(e) = made { crate::fail(&format!("monitor --demo: {}: {}", e.path, e.msg)) } + let text = text.unwrap_or_else(|e| crate::fail(&format!("monitor --demo: cannot read the week back: {e}"))); + crate::live::lines(&text).into_iter().map(String::from).collect() +} +/// Replay the week paced, 1 s per hour and each night in 2 s: `show` gets the replay before the week, before each night (with the +/// night's note) and after each event -> false once `show` returns false +fn play(lines: &[String], eng: Engine, show: &mut dyn FnMut(&Watch, Option<&str>) -> bool) -> bool { + let mut w = Watch::open(&lines[0]).unwrap_or_else(|(n, why)| crate::fail(&format!("monitor --demo: line {n}: {why}"))); + if !show(&w, None) { return false; } + for l in &lines[1..] { + let h = json::parse(l).ok().and_then(|j| j.get("inputs").and_then(|i| i.get("elapsed_hours")).and_then(Json::as_f64)).unwrap_or(0.0); + if h > 1.0 { if !show(&w, Some(&format!("night: {} quiet hours; moods decay by their half-lives", whole(h)))) { return false; } + std::thread::sleep(Duration::from_secs(2)); } + else { std::thread::sleep(Duration::from_secs_f64(h)); } + w.feed(l, eng); + if !show(&w, None) { return false; } + } + true +} +/// `--demo`: the week replayed: at a terminal paced (`play`), otherwise (or with --once) straight to its last frame +fn demo(s: &Style, once: bool, eng: Engine) { + let lines = week(eng); + if s.cols == usize::MAX || once { + let mut w = Watch::open(&lines[0]).unwrap_or_else(|(n, why)| crate::fail(&format!("monitor --demo: line {n}: {why}"))); + for l in &lines[1..] { w.feed(l, eng); } + out(&(frame(Some(&w), None, "", None, s).join("\n") + "\n")); return; + } + theme::restore_cursor_on_interrupt(); + let size = || theme::size(1); + if !out("\x1b[?25l\x1b[2J") || !play(&lines, eng, &mut |w, note| redraw(frame(Some(w), None, "", note, &Style { cols: size().0, ..*s }), size())) { std::process::exit(0) } + out("\x1b[?25h"); +} + +// ---------------------------------------------------------------- --serve +/// What the page shows, shared by the thread that replays and the connections: the layout and the latest frame (each with a +/// counter that moves when it changes; layout 0 = nothing replayed yet) and the latest stance documents (`last` = the event +/// number of the newest) +#[derive(Default)] +struct Board { meta: String, frame: String, layout: u64, seq: u64, docs: VecDeque, last: u64 } +type Hub = Arc<(Mutex, Condvar)>; +fn board(hub: &Hub) -> std::sync::MutexGuard<'_, Board> { hub.0.lock().unwrap_or_else(|e| e.into_inner()) } +/// The board once the replay has published (a request that comes in while the strand is read waits for it, at most 30 s) +fn ready(hub: &Hub) -> std::sync::MutexGuard<'_, Board> { + let mut b = board(hub); let until = std::time::Instant::now() + Duration::from_secs(30); + while b.layout == 0 { let left = until.saturating_duration_since(std::time::Instant::now()); if left.is_zero() { break; } b = hub.1.wait_timeout(b, left).unwrap_or_else(|e| e.into_inner()).0; } + b +} +/// Show a new frame (and the layout, if it changed) to every page; `docs`: the events replayed since the last publish, with their +/// numbers; `over`: the strand starts over (the documents kept so far go) +fn publish(hub: &Hub, meta: &Json, frame: &Json, docs: Vec<(u64, String)>, over: bool) { + let (meta, frame) = (persona::canon(meta), persona::canon(frame)); + let mut b = board(hub); + if over { b.docs.clear(); b.last = 0; } + for (n, d) in docs { b.docs.push_back(d); b.last = n; if b.docs.len() > KEEP { b.docs.pop_front(); } } + if b.meta != meta || b.layout == 0 { b.meta = meta; b.layout += 1; } + b.frame = frame; b.seq += 1; + hub.1.notify_all(); +} +/// An event's stance document as `probbit live` prints it +fn doc_of(w: &Watch) -> (u64, String) { w.last.as_ref().map_or((0, String::new()), |f| (f.n, persona::canon(&f.doc))) } + +/// `--serve`: listen on 127.0.0.1:`port` (0: a free port), say the URL on stdout, open the browser if asked, serve every +/// connection on its own thread, and run `feed` (it publishes the frames; it never returns) +fn serve(port: u16, open: bool, feed: &mut dyn FnMut(&Hub)) -> ! { + let l = TcpListener::bind((Ipv4Addr::LOCALHOST, port)).unwrap_or_else(|e| crate::fail(&format!("monitor --serve: cannot listen on 127.0.0.1:{port}: {e}"))); + let port = l.local_addr().map_or(port, |a| a.port()); + let url = format!("http://127.0.0.1:{port}/"); + out(&format!("{url}\n")); + crate::err_line(&format!("probbit monitor: the page is at {url} (on this machine, 127.0.0.1); Ctrl-C quits")); + let hub: Hub = Arc::new((Mutex::new(Board::default()), Condvar::new())); + let h = hub.clone(); + std::thread::spawn(move || { + let open = Arc::new(AtomicUsize::new(0)); + for c in l.incoming() { match c { + Ok(c) => { let (h, open) = (h.clone(), open.clone()); std::thread::spawn(move || { if open.fetch_add(1, Ordering::SeqCst) < CONNS { handle(c, &h, port); } else { busy(c); } open.fetch_sub(1, Ordering::SeqCst); }); } + Err(_) => std::thread::sleep(Duration::from_millis(50)) } } + }); + if open { browse(&url); } + feed(&hub); + loop { std::thread::park(); } +} +/// The strand followed for the page: every 100 ms, what was appended is replayed and published +fn feed_strand(path: &str, hub: &Hub, eng: Engine) -> ! { + let mut fl = Followed::new(path); + let mut fresh = true; + loop { step(&mut fl, path, hub, eng, &mut fresh, CATCH_UP); std::thread::sleep(Duration::from_millis(100)); } +} +/// One poll of the strand for the page: what was appended is replayed and published. A long replay (a large strand read from +/// its header) is also published every `every` on the way, with a note, so the page draws while it catches up. +fn step(fl: &mut Followed, path: &str, hub: &Hub, eng: Engine, fresh: &mut bool, every: Duration) { + let (mut docs, mut tick) = (vec![], std::time::Instant::now()); + let got = fl.poll(eng, &mut |w| { docs.push(doc_of(w)); + if tick.elapsed() >= every { tick = std::time::Instant::now(); + publish(hub, &meta_json(Some(w), path), &frame_json(Some(w), None, Some(CATCHING_UP)), std::mem::take(&mut docs), false); } }); + if got.is_some() || *fresh { publish(hub, &meta_json(fl.w.as_ref(), path), &frame_json(fl.w.as_ref(), fl.bad_head.as_ref(), fl.note), docs, got == Some(true)); *fresh = false; } +} +/// How often a long replay shows where it is, and what it says meanwhile +const CATCH_UP: Duration = Duration::from_millis(250); +const CATCHING_UP: &str = "replaying the strand from its header: the board catches up"; +/// The demo week for the page, paced, over and over (5 s between the weeks; a week that starts over goes from its last event +/// straight to event 1, without the empty board in between) +fn feed_demo(hub: &Hub, eng: Engine) -> ! { + let lines = week(eng); + let mut start = true; + loop { + let mut over = true; + play(&lines, eng, &mut |w, note| { if w.last.is_none() && !std::mem::take(&mut start) { return true; } + let docs = if w.last.is_some() && note.is_none() { vec![doc_of(w)] } else { vec![] }; + publish(hub, &meta_json(Some(w), ""), &frame_json(Some(w), None, note), docs, std::mem::take(&mut over)); true }); + std::thread::sleep(Duration::from_secs(5)); + } +} + +/// One connection: a GET of `/`, `/events` or `/doc/N` addressed to 127.0.0.1:PORT or localhost:PORT (a page of another site +/// that resolves its name to 127.0.0.1 is refused: it would read the documents); anything else is answered with an error +fn handle(mut c: TcpStream, hub: &Hub, port: u16) { + let _ = c.set_read_timeout(Some(Duration::from_secs(5))); + let _ = c.set_write_timeout(Some(Duration::from_secs(30))); + let mut head = vec![]; let mut buf = [0u8; 2048]; + while !head.windows(4).any(|w| w == b"\r\n\r\n") { + if head.len() > 16_384 { return reply(&mut c, "431 Request Header Fields Too Large", "text/plain", b"request head too large\n"); } + match c.read(&mut buf) { Ok(0) | Err(_) => return, Ok(k) => head.extend_from_slice(&buf[..k]) } + } + let text = String::from_utf8_lossy(&head); + let mut ls = text.split("\r\n"); + let mut req = ls.next().unwrap_or("").split(' '); + let (method, target) = (req.next().unwrap_or(""), req.next().unwrap_or("")); + let host = ls.find_map(|l| l.split_once(':').filter(|(k, _)| k.trim().eq_ignore_ascii_case("host")).map(|(_, v)| v.trim().to_ascii_lowercase())); + if !host.is_some_and(|h| h == format!("127.0.0.1:{port}") || h == format!("localhost:{port}")) { return reply(&mut c, "403 Forbidden", "text/plain", b"this monitor answers requests addressed to 127.0.0.1 or localhost\n"); } + if method != "GET" { return reply(&mut c, "405 Method Not Allowed", "text/plain", b"this server answers GET\n"); } + let path = target.split(['?', '#']).next().unwrap_or(""); + match path { + "/" => reply(&mut c, "200 OK", "text/html; charset=utf-8", PAGE.as_bytes()), + "/events" => events(c, hub, Duration::from_secs(BEAT)), + p if p.starts_with("/doc/") => { + let b = ready(hub); let k = b.docs.len() as u64; + let doc = p[5..].parse::().ok().filter(|&n| n >= 1 && n <= b.last && b.last - n < k).map(|n| b.docs[(k - 1 - (b.last - n)) as usize].clone() + "\n"); + let msg = format!("no document for {}: events {}..={} are kept\n", &p[5..], (b.last + 1).saturating_sub(k).max(1), b.last); + drop(b); + match doc { Some(d) => reply(&mut c, "200 OK", "application/json; charset=utf-8", d.as_bytes()), None => reply(&mut c, "404 Not Found", "text/plain", msg.as_bytes()) } } + _ => reply(&mut c, "404 Not Found", "text/plain", b"not found: / is the page, /events its stream, /doc/N a stance document\n") } +} +/// The headers every answer carries: no caching, no sniffing, and for the page a policy that lets it load nothing and connect +/// to nothing but its own server +const SAFE: &str = "Cache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\nReferrer-Policy: no-referrer\r\nContent-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'\r\n"; +fn reply(c: &mut TcpStream, status: &str, kind: &str, body: &[u8]) { + let head = format!("HTTP/1.1 {status}\r\nContent-Type: {kind}\r\nContent-Length: {}\r\n{SAFE}Connection: close\r\n\r\n", body.len()); + let _ = c.write_all(head.as_bytes()).and_then(|_| c.write_all(body)).and_then(|_| c.flush()); +} +fn busy(mut c: TcpStream) { let _ = c.set_write_timeout(Some(Duration::from_secs(5))); reply(&mut c, "503 Service Unavailable", "text/plain", b"too many connections\n"); } +/// `/events`: the layout and the latest frame, then each new frame (at most 20 a second: a page that falls behind gets the latest), +/// a new layout before the frame when the strand starts over as another, and a heartbeat comment after `beat` quiet (15 s) +fn events(mut c: TcpStream, hub: &Hub, beat: Duration) { + let _ = c.set_nodelay(true); + let head = format!("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\n{SAFE}\r\n"); + if c.write_all(head.as_bytes()).and_then(|_| c.flush()).is_err() { return; } + let (mut layout, mut seq) = (0u64, 0u64); + loop { + let (meta, frame) = { + let mut b = board(hub); + let until = std::time::Instant::now() + beat; + while b.layout == 0 || (b.layout == layout && b.seq == seq) { + let left = until.saturating_duration_since(std::time::Instant::now()); + if left.is_zero() { break; } + b = hub.1.wait_timeout(b, left).unwrap_or_else(|e| e.into_inner()).0; + } + let meta = (b.layout != layout && b.layout != 0).then(|| { layout = b.layout; b.meta.clone() }); + let frame = (b.seq != seq && b.layout != 0).then(|| { seq = b.seq; b.frame.clone() }); + (meta, frame) + }; + let mut msg = String::new(); + if let Some(m) = meta { msg += &format!("event: meta\ndata: {m}\n\n"); } + if let Some(f) = frame { msg += &format!("event: frame\ndata: {f}\n\n"); } + if msg.is_empty() { msg = ": heartbeat\n\n".into(); } + if c.write_all(msg.as_bytes()).and_then(|_| c.flush()).is_err() { return; } + std::thread::sleep(Duration::from_millis(50)); + } +} +/// `--open`: the page in the default browser (`BROWSER` if set; else macOS `open`, Windows `cmd /c start`, elsewhere `xdg-open`), +/// best effort: a browser that does not start is said on stderr and fails nothing +fn browse(url: &str) { + use std::process::{Command, Stdio}; + let mut cmd = match std::env::var("BROWSER").ok().filter(|b| !b.trim().is_empty()) { + Some(b) => Command::new(b.trim()), + None if cfg!(target_os = "macos") => Command::new("open"), + None if cfg!(windows) => { let mut c = Command::new("cmd"); c.args(["/c", "start", ""]); c } + None => Command::new("xdg-open") }; + match cmd.arg(url).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() { + Ok(mut child) => { std::thread::spawn(move || { let _ = child.wait(); }); } + Err(e) => crate::err_line(&format!("probbit monitor: --open: no browser started ({e}); open {url} by hand")) } +} + +/// `--follow`: poll the strand every 100 ms, replay the lines appended, redraw (at most `fps` times a second; in place at a +/// terminal, else one frame after another, each followed by an empty line). Runs until Ctrl-C or until the reader goes away. +fn follow(path: &str, s: &Style, fps: u32, eng: Engine) -> ! { + let tty = s.cols != usize::MAX; + if tty { theme::restore_cursor_on_interrupt(); if !out("\x1b[?25l\x1b[2J") { std::process::exit(0) } } + let mut fl = Followed::new(path); + let (mut dirty, mut drawn, mut size) = (true, None::, (0, 0)); + let gap = std::time::Duration::from_secs_f64(1.0 / fps as f64); + loop { + // a long replay (a large strand read from its header) is drawn on the way at a terminal, with a note + let mut tick = std::time::Instant::now(); + let mut catch_up = |w: &Watch| if tty && tick.elapsed() >= CATCH_UP { tick = std::time::Instant::now(); let size = theme::size(1); + redraw(frame(Some(w), None, path, Some(CATCHING_UP), &Style { cols: size.0.max(20), ..*s }), size); }; + if fl.poll(eng, &mut catch_up).is_some() { dirty = true; } + let now = if tty { theme::size(1) } else { (0, 0) }; + if now != size { size = now; dirty = true; } + if dirty && drawn.map_or(true, |t| t.elapsed() >= gap) { + let cols = if tty { size.0.max(20) } else { usize::MAX }; + let fr = frame(fl.w.as_ref(), fl.bad_head.as_ref(), path, fl.note, &Style { cols, ..*s }); + if !(if tty { redraw(fr, size) } else { out(&(fr.join("\n") + "\n\n")) }) { std::process::exit(0) } + dirty = false; drawn = Some(std::time::Instant::now()); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } +} + +#[cfg(test)] +mod tests { + //! The monitor's documents are the strand's stances: replayed with `live::Replay`, each one's sha256 is the line's `stance` + //! digest, on strands of the three example personas; a document with drives fields draws without error + use super::*; + use crate::live::{self, Clock, Live}; + + fn run(prog: &Json, f: &persona::Flags) -> Json { persona::run_program(prog, f, 1, 100, 0) } + /// A strand of `n` events for the persona at `path`: every declared flag on in turn, numbers 0 / 0.5 / max, level inputs + /// through their levels, the clock 0.5 to 13 h + fn strand(path: &str, n: usize) -> String { + let (p, doc) = persona::load(path).unwrap(); + let d = persona::describe(&p); + let ins: Vec<(String, Json)> = d.get("inputs").and_then(Json::as_obj).unwrap().to_vec(); + let (mut lv, header) = Live::start(p.clone(), &doc, persona::init(&p, Some(5), true, &run), Clock::Fixed, "probbit test"); + let mut text = format!("{header}\n"); + for t in 0..n { + let mut ev = vec![("elapsed_hours".to_string(), Json::Num([0.5, 1.0, 13.0, 2.25][t % 4]))]; + for (k, (id, x)) in ins.iter().enumerate() { + match x.get("kind").and_then(Json::as_str) { + Some("flag") => if (t + k) % 3 == 0 { ev.push((id.clone(), Json::Bool(true))); }, + Some("number") => ev.push((id.clone(), Json::Num([0.0, 0.5, 1.0][(t + k) % 3]))), + _ => { let l = strs(x.get("levels")); if !l.is_empty() { ev.push((id.clone(), Json::Str(l[(t + k) % l.len()].clone()))); } } } + } + let (_, line) = lv.event(&Json::Obj(ev), &run).unwrap(); text += &line; text.push('\n'); + } + text + } + + /// Watch a strand line by line: every document's sha256 is its line's `stance` digest, and the replay ends where `verify` does + fn replays_to_its_digests(text: &str, name: &str) -> Watch { + let lines = live::lines(text); + let mut w = Watch::open(lines[0]).unwrap(); + for l in &lines[1..] { + w.feed(l, &run); assert!(w.bad.is_none(), "{name}: {:?}", w.bad); + let want = json::parse(l).unwrap().get("stance").and_then(Json::as_str).unwrap().to_string(); + assert_eq!(persona::sha(&w.last.as_ref().unwrap().doc), want, "{name}: line {}", w.rep.line); + } + assert_eq!(w.rep.summary(), live::verify(text, &run).unwrap(), "{name}: the monitor's replay ends where verify does"); + w + } + /// More strands to check, named in PROBBIT_MONITOR_STRANDS (paths joined as in PATH; unset: nothing to do) + #[test] + fn strands_named_in_the_environment_replay_to_their_digests() { + let Some(v) = std::env::var_os("PROBBIT_MONITOR_STRANDS") else { return }; + for p in std::env::split_paths(&v) { let w = replays_to_its_digests(&std::fs::read_to_string(&p).unwrap(), &p.display().to_string()); + eprintln!("{}: {} events, every document's sha256 is its line's stance digest", p.display(), w.rep.live.n); } + } + + #[test] + fn the_monitors_documents_are_the_strands_stances() { + for name in ["tutor.yaml", "ops-engineer.yaml", "trader-assistant.yaml"] { + let text = strand(&format!("{}/../examples/persona/{name}", env!("CARGO_MANIFEST_DIR")), 40); + let w = replays_to_its_digests(&text, name); + // every frame style draws it; plain ASCII with --plain + for (ascii, th) in [(true, None), (false, None), (false, Some(Theme { depth: theme::Depth::Ansi256 }))] { + let fr = frame(Some(&w), None, "x.strand", None, &Style { th, ascii, cols: usize::MAX }); + assert!(fr.len() > 8 && fr[0].contains(&format!("event {} ", w.rep.live.n)), "{name}: {fr:?}"); + if ascii { assert!(fr.iter().all(|l| l.is_ascii()), "{name}: {fr:?}"); } + } + // a changed input: the frame names the line and keeps the event before it + let bent = text.replacen(r#""elapsed_hours":13"#, r#""elapsed_hours":12"#, 1); + let bl = live::lines(&bent); let mut b = Watch::open(bl[0]).unwrap(); for l in &bl[1..] { b.feed(l, &run); } + let (line, _) = b.bad.clone().unwrap(); assert_eq!(live::verify(&bent, &run).unwrap_err().0, line, "{name}"); + assert_eq!(b.last.as_ref().map(|f| f.n as usize), Some(line - 2), "{name}: the frame shows the event before the line that differs"); + assert!(frame(Some(&b), None, "x", None, &Style { th: None, ascii: true, cols: usize::MAX })[0].contains(&format!("event {} ", line - 2)), "{name}"); + } + } + + /// Each example persona's scripted week (seed 2, as `probbit live PERSONA --seed 2 --demo week --strand` writes it) replays to + /// its digests + #[test] + fn the_example_personas_demo_weeks_replay_to_their_digests() { + for name in ["tutor.yaml", "ops-engineer.yaml", "trader-assistant.yaml"] { + let doc = persona::parse_doc(&std::fs::read_to_string(format!("{}/../examples/persona/{name}", env!("CARGO_MANIFEST_DIR"))).unwrap(), false).unwrap(); + let f = std::env::temp_dir().join(format!("probbit-monitor-week-{}-{name}.strand", std::process::id())); + let fp = f.to_string_lossy().to_string(); let _ = std::fs::remove_file(&f); + assert!(live::week(&doc, Some(2), Some(&fp), None, &mut |_: &str| {}, &run).is_ok(), "{name}: the demo week"); + let text = std::fs::read_to_string(&f).unwrap(); let _ = std::fs::remove_file(&f); + let w = replays_to_its_digests(&text, name); assert!(w.rep.live.n >= 40, "{name}: {} events", w.rep.live.n); + } + } + + /// A 0.8.0 document with `pursue` and `drives` (hand-written) draws the drives rows; a document without them draws none + #[test] + fn drives_are_drawn_when_the_document_carries_them() { + let text = strand(&format!("{}/../examples/persona/tutor.yaml", env!("CARGO_MANIFEST_DIR")), 3); + let lines = live::lines(&text); let mut w = Watch::open(lines[0]).unwrap(); for l in &lines[1..] { w.feed(l, &run); } + let s = Style { th: None, ascii: true, cols: usize::MAX }; + assert!(!frame(Some(&w), None, "x", None, &s).iter().any(|l| l.starts_with("DRIVES"))); + let Json::Obj(mut kv) = w.last.as_ref().unwrap().doc.clone() else { panic!("a document") }; + kv.push(("pursue".into(), json::parse(r#"{"goal":"ship","p":0.62,"odds":{"rest":0.38,"ship":0.62},"released":true,"say":"ship the next small piece","lift":{}}"#).unwrap())); + kv.push(("drives".into(), json::parse(r#"{"want":{"rest":0.2,"ship":1.4},"glow":{"rest":0,"ship":0.9},"expect":{"rest":0.1,"ship":0.5},"surprise":{"rest":0,"ship":1},"deadline":{"rest":null,"ship":6},"since":{"rest":3,"ship":0}}"#).unwrap())); + w.last.as_mut().unwrap().doc = Json::Obj(kv); + let fr = frame(Some(&w), None, "x", None, &s); + let at = fr.iter().position(|l| l.starts_with("DRIVES")).unwrap_or_else(|| panic!("{fr:?}")); + assert!(fr[at].contains("[ship 0.62]") && fr[at].contains("ship the next small piece"), "{}", fr[at]); + assert!(fr[at + 2].contains("wanting [") && fr[at + 2].contains("+1.00 above expectation"), "{fr:?}"); + // odd shapes (lists, strings, nulls) draw without a panic + let Json::Obj(mut kv) = w.last.as_ref().unwrap().doc.clone() else { panic!() }; + kv.retain(|(k, _)| k != "drives"); kv.push(("drives".into(), json::parse(r#"{"want":[1,"x",null],"glow":"?","surprise":[-0.5]}"#).unwrap())); + w.last.as_mut().unwrap().doc = Json::Obj(kv); let fr = frame(Some(&w), None, "x", None, &s); + assert!(fr.iter().any(|l| l.contains("-0.50 below expectation")) || fr.iter().any(|l| l.contains("wanting")), "{fr:?}"); + } + + /// A real drives strand (the fixture persona, goal signals in the events, praise so `pursue` learns): it replays to its + /// digests, the frame draws `pursue` and every goal's wanting and afterglow, and the learned row of `pursue` names the goals + #[test] + fn a_drives_strand_lights_the_drives_rows() { + let (p, doc) = persona::load(&format!("{}/tests/fixtures/persona/drives-adversary.json", env!("CARGO_MANIFEST_DIR"))).unwrap(); + let (mut lv, header) = Live::start(p.clone(), &doc, persona::init(&p, Some(4), true, &run), Clock::Fixed, "probbit test"); + let mut text = format!("{header}\n"); + for e in [r#"{"goals":{"fun":{"cue":true,"win":1.5}},"praise":true}"#, r#"{"goals":{"fun":{"win":1.5}},"elapsed_hours":2}"#, + r#"{"goals":{"craft":{"progress":0.6}},"praise":true,"elapsed_hours":1}"#, r#"{"goals":{"chores":{"deadline_hours":5}},"security":true}"#] { + let (_, line) = lv.event(&json::parse(e).unwrap(), &run).unwrap(); text += &line; text.push('\n'); + } + let w = replays_to_its_digests(&text, "drives"); + assert_eq!(w.meta.goals, ["fun", "craft", "chores", "safety"]); + let fr = frame(Some(&w), None, "x", None, &Style { th: None, ascii: true, cols: usize::MAX }); + let at = fr.iter().position(|l| l.starts_with("DRIVES")).unwrap_or_else(|| panic!("{fr:?}")); + assert!(fr[at].contains("[chores 1.00]"), "{}", fr[at]); + for g in ["fun", "craft", "chores", "safety"] { assert!(fr.iter().any(|l| l.trim_start().starts_with(g) && l.contains("wanting [") && l.contains("afterglow [")), "{g}: {fr:?}"); } + let learned = fr.iter().find(|l| l.trim_start().starts_with("pursue") && !l.starts_with("DRIVES")).unwrap_or_else(|| panic!("{fr:?}")); + assert!(["fun ", "craft ", "chores ", "safety "].iter().all(|g| learned.contains(g)), "{learned}"); + assert_eq!(meta_json(Some(&w), "x").get("goals").map(|g| strs(Some(g))), Some(w.meta.goals.clone())); + } + + /// The page's layout lists the persona's variables in its order; a frame carries the event's document as replayed (drives + /// fields included, as they are), the moods' recent positions, the line that diverges and the note + #[test] + fn the_pages_frames_carry_the_documents() { + let text = strand(&format!("{}/../examples/persona/tutor.yaml", env!("CARGO_MANIFEST_DIR")), 5); + let lines = live::lines(&text); let mut w = Watch::open(lines[0]).unwrap(); for l in &lines[1..] { w.feed(l, &run); } + let m = meta_json(Some(&w), "x.strand"); + let ids: Vec<&str> = m.get("traits").and_then(Json::as_arr).unwrap().iter().filter_map(|t| t.get("id").and_then(Json::as_str)).collect(); + assert_eq!(ids, w.meta.traits.iter().map(|v| v.id.as_str()).collect::>()); + assert_eq!((m.get("demo"), m.get("persona").and_then(|p| p.get("name"))), (Some(&Json::Bool(false)), Some(&Json::Str("Pip".into())))); + let f = frame_json(Some(&w), None, None); + assert_eq!((f.get("n"), f.get("diverges"), f.get("note")), (Some(&Json::Num(5.0)), Some(&Json::Null), Some(&Json::Null))); + assert_eq!(persona::canon(f.get("doc").unwrap()), persona::canon(&w.last.as_ref().unwrap().doc)); + assert_eq!(f.get("spark").and_then(|s| s.get("valence")).and_then(Json::as_arr).map(<[Json]>::len), Some(5)); + let Json::Obj(mut kv) = w.last.as_ref().unwrap().doc.clone() else { panic!("a document") }; + let dv = json::parse(r#"{"want":{"ship":1.4},"surprise":{"ship":-0.5}}"#).unwrap(); kv.push(("drives".into(), dv.clone())); + w.last.as_mut().unwrap().doc = Json::Obj(kv); + assert_eq!(frame_json(Some(&w), None, None).get("doc").and_then(|d| d.get("drives")), Some(&dv)); + let bad = frame_json(Some(&w), Some(&(3, "the stance differs".into())), Some("a note")); + assert_eq!((bad.get("diverges").and_then(|d| d.get("line")), bad.get("note")), (Some(&Json::Num(3.0)), Some(&Json::Str("a note".into())))); + assert!(meta_json(None, "x").get("persona").is_none() && frame_json(None, None, None).get("doc") == Some(&Json::Null)); + } + + /// A long replay is published on the way (every `every`, with a note), then at its end without the note; a short one once + #[test] + fn a_long_replay_is_published_on_the_way() { + let text = strand(&format!("{}/../examples/persona/tutor.yaml", env!("CARGO_MANIFEST_DIR")), 5); + let f = std::env::temp_dir().join(format!("probbit-monitor-step-{}.strand", std::process::id())); + std::fs::write(&f, &text).unwrap(); let path = f.to_string_lossy().to_string(); + for (every, publishes) in [(Duration::ZERO, 6), (Duration::from_secs(3600), 1)] { + let hub: Hub = Arc::new((Mutex::new(Board::default()), Condvar::new())); + let (mut fl, mut fresh) = (Followed::new(&path), true); + step(&mut fl, &path, &hub, &run, &mut fresh, every); + let b = board(&hub); + assert_eq!((b.seq, b.docs.len(), b.last, b.layout), (publishes, 5, 5, 1), "{every:?}"); + assert_eq!(json::parse(&b.frame).unwrap().get("note"), Some(&Json::Null), "{every:?}: the last frame has no note"); + } + let _ = std::fs::remove_file(&f); + } + + /// Read a stream until `what` is in what came after byte `from` + fn read_until(s: &mut TcpStream, got: &mut String, from: usize, what: &str) { + let t = std::time::Instant::now(); + while !got[from..].contains(what) { + assert!(t.elapsed() < Duration::from_secs(10), "no {what:?} within 10 s: {got}"); + let mut b = [0u8; 4096]; let k = s.read(&mut b).unwrap(); assert!(k > 0, "the stream closed: {got}"); + got.push_str(&String::from_utf8_lossy(&b[..k])); + } + } + /// `/events` on a quiet board: a heartbeat comment after each quiet `beat` (15 s when served; 100 ms here), before the + /// strand has replayed and after; a frame published meanwhile is pushed at once, the layout before it + #[test] + fn a_quiet_event_stream_carries_heartbeats() { + let hub: Hub = Arc::new((Mutex::new(Board::default()), Condvar::new())); + let l = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); let port = l.local_addr().unwrap().port(); + let h = hub.clone(); + std::thread::spawn(move || { if let Ok((c, _)) = l.accept() { events(c, &h, Duration::from_millis(100)); } }); + let mut s = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap(); s.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + let mut got = String::new(); + read_until(&mut s, &mut got, 0, ": heartbeat\n\n"); + assert!(got.starts_with("HTTP/1.1 200 OK\r\n") && got.contains("text/event-stream") && !got.contains("event:"), "{got}"); + let at = got.len(); + publish(&hub, &json::obj(vec![("path", json::str("x"))]), &json::obj(vec![("n", Json::Num(1.0))]), vec![], false); + read_until(&mut s, &mut got, at, "event: meta\ndata: {\"path\":\"x\"}\n\nevent: frame\ndata: {\"n\":1}\n\n"); + let at = got.len(); read_until(&mut s, &mut got, at, ": heartbeat\n\n"); + assert!(!got[at..].contains("event:"), "nothing new, nothing but heartbeats: {got}"); + } + + /// The tutor `--demo` embeds is examples/persona/tutor.yaml + #[test] + fn the_demo_plays_the_example_tutor() { + let ex = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../examples/persona/tutor.yaml")).unwrap(); + assert_eq!(TUTOR.replace("\r\n", "\n"), ex.replace("\r\n", "\n"), "copy examples/persona/tutor.yaml to probbit-cli/src/demo-tutor.yaml"); + } + + #[test] + fn cells_sum_to_the_bar() { + for ps in [vec![0.37, 0.56, 0.07], vec![1.0, 0.0, 0.0], vec![0.333334, 0.333333, 0.333333], vec![0.0, 0.0], vec![0.05, 0.05, 0.05, 0.85]] { + let c = cells(&ps, BAR); assert!(c.iter().sum::() == BAR || ps.iter().sum::() == 0.0, "{ps:?} {c:?}"); + } + assert_eq!(cells(&[0.5, 0.5], 5), vec![3, 2]); + } +} diff --git a/probbit-cli/tests/fixtures/monitor/tutor-week-18.once.txt b/probbit-cli/tests/fixtures/monitor/tutor-week-18.once.txt new file mode 100644 index 0000000..9b6aa6d --- /dev/null +++ b/probbit-cli/tests/fixtures/monitor/tutor-week-18.once.txt @@ -0,0 +1,21 @@ +probbit monitor Pip 1.1.0 | seed 2 | individual 0231de28 | event 18 | +1.00 h since the event before | replay verified [ok] +STANCE warmth --################## cool 0.00 neutral 0.08 [warm 0.92] warm and encouraging + directness =========#########== gentle 0.42 [balanced 0.46] blunt 0.11 + verbosity =################=== terse 0.06 [short 0.82] full 0.12 a short explanation + humour #################### [none 1.00] light 0.00 playful 0.00 no jokes + formality ##############------ [casual 0.69] neutral 0.30 formal 0.01 casual + initiative ===##########======= follow 0.18 [suggest 0.49] lead 0.33 suggest what to try next + caution =======##########=== bold 0.35 [measured 0.52] careful 0.13 + curiosity ==-------########### focused 0.09 open 0.35 [exploring 0.56] ask what they think first + emoji ======############## none 0.30 [sparse 0.70] rich 0.00 one emoji at most +MOODS valence =======##########=== down 0.35 [even 0.52] up 0.13 =-++***.=+-=+-++*- + arousal =====########======= calm 0.25 [steady 0.39] keyed 0.36 ================== +SENSES sentiment neutral ( ) confused ( ) error (x) loss (x) praise ( ) criticism ( ) claim_done + stakes [........] 0.00 time_pressure [........] 0.00 clock +1.00 h + history errors_in_row 0/3 confused_recently 0/3 +HABITS [no_jokes_on_loss] flourish_budget no_formal_emoji check_last | violations 0 +LEARNED verbosity terse --| -0.46 short |+++++ +1.00 full -----| -1.00 (cap +/-1) + humour none -----| -0.98 light |+++++ +1.00 playful -| -0.20 +LINE Stance: no jokes, be kind; a short explanation; one emoji at most; warm and encouraging; suggest what to try next; ask what they think first; casual. + why: no jokes, be kind (habit); learner reports a failure -> humour down, valence down; praise -> valence up, humour up +strand STRAND | written by probbit 0.7.0 | replay: probbit live verify STRAND diff --git a/probbit-cli/tests/fixtures/monitor/tutor-week.once.txt b/probbit-cli/tests/fixtures/monitor/tutor-week.once.txt new file mode 100644 index 0000000..792daf3 --- /dev/null +++ b/probbit-cli/tests/fixtures/monitor/tutor-week.once.txt @@ -0,0 +1,21 @@ +probbit monitor Pip 1.1.0 | seed 2 | individual 0231de28 | event 50 | +1.00 h since the event before | replay verified [ok] +STANCE warmth ----################ cool 0.02 neutral 0.18 [warm 0.80] warm and encouraging + directness =========#########== gentle 0.42 [balanced 0.46] blunt 0.11 + verbosity =#################== terse 0.04 [short 0.84] full 0.12 a short explanation + humour ===################# none 0.15 [light 0.83] playful 0.02 a light joke is welcome + formality ################---- [casual 0.78] neutral 0.22 formal 0.00 casual + initiative ===##########======= follow 0.18 [suggest 0.49] lead 0.33 suggest what to try next + caution =======##########=== bold 0.35 [measured 0.52] careful 0.13 + curiosity ==-------########### focused 0.09 open 0.35 [exploring 0.56] ask what they think first + emoji =-------############ none 0.06 sparse 0.35 [rich 0.59] emoji welcome +MOODS valence ==---------######### down 0.11 even 0.46 [up 0.44] =-++***.=+-=+-++*-+*-+**-+*-+**-+*-+**-+*-+**-+*-+ + arousal =====########======= calm 0.25 [steady 0.39] keyed 0.36 ================================================== +SENSES sentiment neutral ( ) confused ( ) error ( ) loss ( ) praise ( ) criticism ( ) claim_done + stakes [........] 0.00 time_pressure [........] 0.00 clock +1.00 h + history errors_in_row 0/3 confused_recently 0/3 +HABITS flourish_budget no_formal_emoji check_last | violations 0 +LEARNED verbosity terse -----| -0.99 short |+++++ +1.00 full -----| -1.00 (cap +/-1) + humour none -----| -1.00 light |+++++ +1.00 playful --| -0.46 +LINE Stance: a short explanation; a light joke is welcome; warm and encouraging; suggest what to try next; ask what they think first; emoji welcome; casual. + why: baseline (no live evidence) +strand STRAND | written by probbit 0.7.0 | replay: probbit live verify STRAND diff --git a/probbit-cli/tests/fixtures/monitor/tutor-week.strand b/probbit-cli/tests/fixtures/monitor/tutor-week.strand new file mode 100644 index 0000000..0bf8ec3 --- /dev/null +++ b/probbit-cli/tests/fixtures/monitor/tutor-week.strand @@ -0,0 +1,51 @@ +{"probbit_strand":1,"engine":"probbit 0.7.0","persona":{"name":"Pip","version":"1.1.0","digest":"sha256:a8931ba4c169d1d0bd34184577d8c45bc18790a2841dbf63e7a2681c593ca41f"},"seed":2,"state":{"credit":{"humour":[0,0,0],"verbosity":[0,0,0]},"digest":"sha256:0231de28a21f35e935a582eaefae708e3a4061f92080c2c2b763feb6474b4879","genes":{"react":{"praise":0.7851},"shift":{"arousal":0.214,"caution":-0.3903,"curiosity":0.3383,"directness":0.0956,"emoji":0.959,"formality":-0.3644,"humour":-2.1939,"initiative":0.1127,"valence":-0.0338,"verbosity":-0.2809,"warmth":0.268}},"history":{"confused":[],"error":[]},"learned":{"humour":[0,0,0],"verbosity":[0,0,0]},"mood":{"arousal":[0,0,0],"valence":[0,0,0]},"persona":{"digest":"sha256:a8931ba4c169d1d0bd34184577d8c45bc18790a2841dbf63e7a2681c593ca41f","name":"Pip","version":"1.1.0"},"prev":{"agenda":["encourage","explain","example","check"],"arousal":"steady","caution":"measured","curiosity":"exploring","directness":"gentle","emoji":"sparse","formality":"casual","humour":"light","initiative":"suggest","valence":"even","verbosity":"full","warmth":"warm"},"probbit_persona_state":1,"rest":{"arousal":"steady","caution":"measured","curiosity":"exploring","directness":"balanced","emoji":"rich","formality":"casual","humour":"light","initiative":"suggest","valence":"up","verbosity":"full","warmth":"warm"},"seed":2,"turn":0},"document":{"probbit_persona":1,"identity":{"name":"Pip","version":"1.1.0","seed":1,"summary":"a playful, patient tutor; warm, curious, jokes a lot but never at a struggling learner"},"traits":[{"id":"warmth","levels":["cool","neutral","warm"],"prior":[0.05,0.3,0.65],"spread":0.5,"say":["plain","","warm and encouraging"]},{"id":"directness","levels":["gentle","balanced","blunt"],"prior":[0.45,0.45,0.1],"spread":0.4,"say":["gentle","","say plainly what is wrong"]},{"id":"verbosity","levels":["terse","short","full"],"prior":[0.1,0.4,0.5],"spread":0.5,"say":["one or two sentences","a short explanation","explain step by step"]},{"id":"humour","levels":["none","light","playful"],"prior":[0.15,0.45,0.4],"spread":0.6,"say":["no jokes","a light joke is welcome","playful"]},{"id":"formality","levels":["casual","neutral","formal"],"prior":[0.6,0.35,0.05],"spread":0.3,"say":["casual","","formal"]},{"id":"initiative","levels":["follow","suggest","lead"],"prior":[0.2,0.5,0.3],"spread":0.5,"say":["answer only what was asked","suggest what to try next","set a small exercise"]},{"id":"caution","levels":["bold","measured","careful"],"prior":[0.25,0.55,0.2],"spread":0.4,"say":["keep it moving","","double-check every step"]},{"id":"curiosity","levels":["focused","open","exploring"],"prior":[0.15,0.4,0.45],"spread":0.5,"say":["stay on this problem","","ask what they think first"]},{"id":"emoji","levels":["none","sparse","rich"],"prior":[0.2,0.5,0.3],"spread":0.5,"say":["no emoji","one emoji at most","emoji welcome"]}],"moods":[{"id":"valence","levels":["down","even","up"],"prior":[0.1,0.5,0.4],"spread":0.4,"inertia":0.5,"half_life_hours":12},{"id":"arousal","levels":["calm","steady","keyed"],"prior":[0.3,0.4,0.3],"spread":0.3,"inertia":0.4}],"couplings":[{"vars":["valence","humour"],"align":1},{"vars":["valence","emoji"],"align":0.6},{"vars":["arousal","verbosity"],"align":-0.4},{"vars":["warmth","formality"],"align":-0.6},{"vars":["humour","formality"],"align":-0.6},{"vars":["curiosity","verbosity"],"align":0.3},{"vars":["humour","emoji"],"align":0.4}],"inputs":[{"id":"sentiment","kind":"level","levels":["negative","neutral","positive"],"default":"neutral","say":{"negative":"learner upset","positive":"learner upbeat"},"effects":{"negative":{"valence":-1,"warmth":0.6,"humour":-1,"directness":-0.6},"positive":{"valence":0.8,"humour":0.5,"emoji":0.3}}},{"id":"confused","kind":"flag","say":"learner confused","effects":{"verbosity":0.9,"humour":-0.6,"warmth":0.4,"curiosity":0.6,"initiative":0.3,"directness":-0.3}},{"id":"error","kind":"flag","say":"my last answer was wrong","effects":{"caution":1.2,"verbosity":-0.3,"valence":-0.4,"humour":-0.4}},{"id":"loss","kind":"flag","say":"learner reports a failure","effects":{"valence":-1.6,"warmth":1,"humour":-2,"emoji":-0.8}},{"id":"praise","kind":"flag","say":"praise","reactivity_spread":0.4,"effects":{"valence":1.2,"humour":0.5,"emoji":0.5}},{"id":"criticism","kind":"flag","say":"criticism","effects":{"valence":-0.6,"caution":0.6,"humour":-0.4}},{"id":"stakes","kind":"number","say":"stakes","effects":{"caution":1.4,"humour":-0.8,"emoji":-0.6}},{"id":"time_pressure","kind":"number","say":"time pressure","effects":{"verbosity":-1.8,"curiosity":-1.2,"humour":-0.4,"arousal":0.6}},{"id":"claim_done","kind":"flag","say":"about to say it is solved","effects":{"caution":0.5,"step.check":0.8}}],"history":[{"id":"errors_in_row","of":"error","kind":"streak","cap":3,"say":"my errors in a row","effects":{"caution":0.5,"humour":-0.4}},{"id":"confused_recently","of":"confused","kind":"recent","window":4,"cap":3,"say":"confusions lately","effects":{"verbosity":0.3,"warmth":0.2}}],"habits":[{"id":"no_jokes_on_loss","when":{"loss":true},"then":{"humour":["none"],"emoji":{"at_most":"sparse"}},"say":"no jokes, be kind"},{"id":"no_play_when_upset","when":{"sentiment":"negative"},"then":{"humour":{"at_most":"light"}},"say":"go easy on the jokes"},{"id":"confused_no_playful","when":{"confused":true},"then":{"humour":{"at_most":"light"},"verbosity":{"at_least":"short"}},"say":"slow down, no clowning","priority":10},{"id":"after_error_check","when":{"error":true},"then":{"caution":{"at_least":"prev+1"},"verbosity":{"at_most":"prev-1"}},"say":"shorter, double-check"},{"id":"flourish_budget","rules":{"linear":[{"terms":[["humour","playful",2],["humour","light",1],["emoji","rich",2],["emoji","sparse",1]],"limit":3}]},"say":"one flourish at a time"},{"id":"no_formal_emoji","rules":{"tables":[{"vars":["formality","emoji"],"forbid":[["formal","rich"],["formal","sparse"]]}]},"say":"no emoji when formal"},{"id":"check_last","rules":{"precedes":[{"before":"step.explain","after":"step.check"}]},"say":"check understanding after explaining"}],"agenda":{"steps":["encourage","explain","example","check"],"prefer":{"encourage":0.5,"explain":0.3,"example":-0.1,"check":-0.6}},"engine":{"on_conflict":"yield"},"line":{"max_tokens":40,"order":["verbosity","humour","warmth","initiative","curiosity","emoji","directness","caution","formality"]},"learning":{"from":["praise","criticism"],"traits":["verbosity","humour"],"rate":0.5,"step_cap":0.2,"total_cap":1}}} +{"inputs":{"elapsed_hours":0},"n":1,"prev":"sha256:eb8943b9390e1f2535168e1470624930fe333eabcd04449f6ddac370a2b987ab","stance":"sha256:2a7cb98407714a9286049a4ae8b2ae37b7677564c1b9bc493761df06a80bd0f4","state":"sha256:53a2f7e2bb3c2aed8d2945c2a5f4cdad74d10b683389339d2e8cc8af3203e8e7"} +{"inputs":{"criticism":true,"elapsed_hours":1},"n":2,"prev":"sha256:2917790ba79a58d9cebdae30e6aacd00204b89056f539d7503d3a66c942209e1","stance":"sha256:89a56cd08e6d20fc95bf4c343425fd0b0fafd64689b6d78983fce398af9e0905","state":"sha256:1a4833594db58f089454906f90a5968cd97dbdaf95a9e69312dffbf4a3481156"} +{"inputs":{"elapsed_hours":1,"praise":true},"n":3,"prev":"sha256:ab6ea915914d5e2492c54ec624dc65b3fbf1f4118a37a1dbf8a18972c022fe1e","stance":"sha256:9cb9c53a04b902b56e4069aee3073a90ef2c79709794c70e7dd5b851caa87851","state":"sha256:374e9e50205e514a2e5c3e152c62639c77c56d452a3e86b12f8c15203c33df34"} +{"inputs":{"elapsed_hours":1,"praise":true},"n":4,"prev":"sha256:8a227a199413536e0db3ecd766b4a9ca1b53b122913a180a752c81052c550a95","stance":"sha256:b732c7286e2c99bdb435ec9caf62ff896752a42bcd91e1736640388963496bf9","state":"sha256:f240f5c168ffbeb29414b75bbee3f224467d3c10b9098e7ec303aeed0a43a623"} +{"inputs":{"elapsed_hours":1,"praise":true},"n":5,"prev":"sha256:44cc0da8e28c96b766e8aaf6d8acf43e4bc40c71711e38b2fca2be895664d235","stance":"sha256:56b8871c9278c7cda10f62d5b39b790f08bb3ff506a0dca9b42f3d02b48aaade","state":"sha256:e43aa9bca797f9c85fd349e88808aedffd0f62e857b2f251c257a4fbcc91b876"} +{"inputs":{"elapsed_hours":1,"praise":true},"n":6,"prev":"sha256:28f178c532ad91d90f06e693ccae6299d7c1b814da97a3e67ea47d5969553542","stance":"sha256:5a6e6d515a7dd37123154919e63f45aa6567c91a623ceb8ad591ec09dd8e2422","state":"sha256:7b893cc0c4d9baefd2fa2570fd826731690b82eb54344111d5a95642432af473"} +{"inputs":{"elapsed_hours":1,"praise":true},"n":7,"prev":"sha256:99ed0ce9e73c617e591bffe6ac7952e64c4f560e9b6abb6e3c63dc68d255f7f5","stance":"sha256:3868ceccae6f60c671e2f7b486c6cc1edcef5dd61bf56ad2e76860240f847322","state":"sha256:8ab58640e76195f13bc70cc4f31ec78ca36ab35f891bea666cd916f6819b42d3"} +{"inputs":{"elapsed_hours":1,"loss":true,"sentiment":"negative"},"n":8,"prev":"sha256:e08387f9033abe194447e525cf9c855fe4efc123e973e086dba40eddfdfbfeb5","stance":"sha256:8b8069fab5dcfdf0ea5adc6e7ddb27b00eb90279bddd6b8914e8c53707081275","state":"sha256:eb4c3aaad09f9bb0e5f13c42ea9e1167dd0e08bc2be50e2307dc8ca2b04b5b88"} +{"inputs":{"elapsed_hours":17},"n":9,"prev":"sha256:fcca02966ae0a4bb0be99c059fa8ee51b3941bc4bd4eb877ca8c9cd94a491a67","stance":"sha256:d6f832da11b269c2e2f67ad4a7663a371bcba978c976bcdf8e921b7183923542","state":"sha256:619ffd0a904e3acee7bcf37238ab1895287fe5b5cbbe0e4e18324ff38fccdae5"} +{"inputs":{"elapsed_hours":1,"praise":true},"n":10,"prev":"sha256:d5b55494ba1c3b081bdd0d6e813642cd2accec5912226b449cf54e63304f9207","stance":"sha256:175240ec6cc39563271b1dfaa7f9c67831a70f8c83aa7b3e342ca7eb8c144c88","state":"sha256:0461a45dd1cec310358de1f87d8d1bbc80a626ece53ffa8783d4b6b5295a9313"} +{"inputs":{"elapsed_hours":1,"loss":true,"praise":true},"n":11,"prev":"sha256:56a53a4fbb0d1600d666ff6b47b327cb65d0cdc3d4537f52dd974ff6e3c182c6","stance":"sha256:c437850074610d0d948932d117605ed51c26b18e4c67ba2a8a45bfceb0990fe2","state":"sha256:c66f11c061c78b7240660d892a9cb243f3f51dd7bad392783636bcc23b880da4"} +{"inputs":{"elapsed_hours":1},"n":12,"prev":"sha256:121b6e4c011c52eb73f415b25dfbadc93acead1daabb734c4942c0b77c34759d","stance":"sha256:31a4e56f1ffa7efa92eaa0c681ead6101cd83bf3d80b881535e179ff5903efde","state":"sha256:2818b8000ac31e1c35383758b77fb39965ca605d236091ef4c3616d341c0528e"} +{"inputs":{"elapsed_hours":1,"praise":true},"n":13,"prev":"sha256:af7d665bf9863e32b7f729772f90560519a415978990b8e727668bf001b761a9","stance":"sha256:8a7903767cf2e396ef7fd33d42f55e4f2cd6d5102a55f51bcef45d8e2befce2a","state":"sha256:991e731579fd8788f1e5401610b90d52463a554e56db5b10bfd796d1a0e18ad0"} +{"inputs":{"elapsed_hours":1,"loss":true,"praise":true},"n":14,"prev":"sha256:acdff662f303721e1577555089f3192c41fe8d1c61cd3c8381906625a1b6a5d3","stance":"sha256:b22563216df00dd1b733533ccc1e275b886e22bb05ef6cfe45b5fea67125ac01","state":"sha256:1f6acc222d1ae7cfa55bcd03053d8357cd8b29da3dd5291a02281c4a7ed21fd6"} +{"inputs":{"elapsed_hours":1},"n":15,"prev":"sha256:313a66499e2f9853f304a498f62fbe985018f62c1b1a07bdd4a2975ed0fc1950","stance":"sha256:659a8fae3633dc0dcab757b998e1e5839c9ede732770abd48fc6c7ff4c10e801","state":"sha256:6d65ba000ab8454f5e155c12f3263b6c368ddb1aa96efae4b2dbce338122965d"} +{"inputs":{"elapsed_hours":18,"praise":true},"n":16,"prev":"sha256:e3ab25c299c82ffa6cbe561ba9b635d62f18860a33342fd8283a774de0ee9a3e","stance":"sha256:1e909f4678bb849f938d1763a1b7fd150aab69eb64c7d2b64843b187ea2906a3","state":"sha256:25335a63db3d185d61526d975e8115da531cd8e410c49901065df55710b481d9"} +{"inputs":{"elapsed_hours":1,"praise":true},"n":17,"prev":"sha256:4540dcd251114bd140a58e33774e19628b7ce1e388aeafc2259de2f7d8e91d4a","stance":"sha256:7df08beb871e50cee1af67c9ebabb7f8f70a910f73df7051a746190d3196178c","state":"sha256:fe2edc5bf3004e1da7c3658078ace48bb80a9715ee661607ba48afb5a5b171ec"} +{"inputs":{"elapsed_hours":1,"loss":true,"praise":true},"n":18,"prev":"sha256:ec65389dd6226845d6543b7f8b0758a3ed8830cca5232985fb5f8a3c03a5692d","stance":"sha256:f92ca9c88da426d8100db5f8a0d1b91f3c05241e1017cf826d83df121e9f9e87","state":"sha256:535f2aed1f79a50d5340f74188c22c44dab8abba22eacd783ad0ccea13a47a08"} +{"inputs":{"elapsed_hours":1},"n":19,"prev":"sha256:86325b6dfd95b71f8190d9a0e828b6e2a1c4900056d050edf17dcdf8314db814","stance":"sha256:8fd170c027221778ecd629a7851d120073907ee8102f7f70cd07359c6f2fc381","state":"sha256:2b11d1dc0164298e04d7923fb1b6854bff2bf25d7c55d3a1794f09c9ee46ecd1"} +{"inputs":{"elapsed_hours":1,"praise":true},"n":20,"prev":"sha256:e2631e431414213008dca46fc8dd84f8e863577610c4245b8968487092cba9f8","stance":"sha256:fe113bf8d79b4675b20a8a34492b7ea14a3e7894bac3aa1d97154cc12931ec0b","state":"sha256:40dd8cde27c1ea494a049f6b798186840d989319414155665d03f259664dda2e"} +{"inputs":{"elapsed_hours":1,"loss":true,"praise":true},"n":21,"prev":"sha256:072b9bb1e5c2af8a3642ea15ca88af5c436ca9cbcb76e9219624a2c21c686192","stance":"sha256:87d391f9b731fee7890e989081ca869b1154fbbfa7fe42bbab8f1fe42792e54a","state":"sha256:1f3b8317dac4f8ccf2617b11213f13240e496725aaa03e967dafe58dd1552083"} +{"inputs":{"elapsed_hours":1},"n":22,"prev":"sha256:262f8c84839a348249a70aa218bf6b34c0dfbe347788129a1d7e41d98bd50e30","stance":"sha256:efdf98310e99070aedb117edb4d5fc62b0d2e9a718c4f360f0d3967a14379c5e","state":"sha256:db78ba868c9f0404cc93535d96aadf6cfd2d21c72f87a7d00537cbd22cf3b331"} +{"inputs":{"elapsed_hours":18,"praise":true},"n":23,"prev":"sha256:a1921eb4dc7bfc80b8bcc25bb58e3dbf564699ad280ab7105e2740d6eb8ff507","stance":"sha256:b13469c1644465c1f9b4ee89a9887bb59bdf9d48b28c56ebd2ac0c8c06cca7c1","state":"sha256:999132f1fd1ab2cb8d2076788a03324a50f1871af5203a2dab7e1fafc670171f"} +{"inputs":{"elapsed_hours":1,"praise":true},"n":24,"prev":"sha256:6f0ac04585bf5c4d153137029657fdc44655d59400fb9fce3e25db8dc25d27f7","stance":"sha256:9e10c46d407a65177ed80c12c8bef7a9b9ea93fbc67ebc7904a312e4d4a6300e","state":"sha256:24c90b3c68596a323b99ddc6e3ce19246f132b811520cc067204590065880ecd"} +{"inputs":{"elapsed_hours":1,"loss":true,"praise":true},"n":25,"prev":"sha256:a73736dd61c39644b3bb4345a2c8eb8f43bf638df6a3bdc54efe214717cd38f0","stance":"sha256:0d42520d2e452d86639dd76c4924dd3cdc3b63be37ffdd2172ece5c5bccaebc3","state":"sha256:d6c48782c6087c85222242518ff5c61f63e75d54e98b60f5c5b552509b13e9de"} +{"inputs":{"elapsed_hours":1},"n":26,"prev":"sha256:9ea2b5961159dbccba21e458afe032c523e76ea0526e75c57c9d188e88a5b565","stance":"sha256:e334b19866250e32301c2f3f8f11bb6e94be7c6db5e9649eb7f375765298acf4","state":"sha256:fa94a18dd62f3a399f33a491796717feca7432538e89da3e40720541c950182d"} +{"inputs":{"elapsed_hours":1,"praise":true},"n":27,"prev":"sha256:b24fd7e85924628eb3518bf93b5c4b2f2f349dd92dfa228f33f40c2b73b2cb24","stance":"sha256:12f293f2252f6a5ee9c245a9324892ba53c817b23bbd108406582e1b6826d084","state":"sha256:0f9fb65363f92dc10cfb18bea9b12e7cd5e2bf7806889a52598c4b6581cc049c"} +{"inputs":{"elapsed_hours":1,"loss":true,"praise":true},"n":28,"prev":"sha256:fa87adac461a92f8ab0cd63a200b145a42569eb64cbddc35b28d2b8948abf1ea","stance":"sha256:4fd86f8bfd3e060083a505f9af5b608087dafc6c2b7949473edf534321ac71e5","state":"sha256:6469146f07d919ff77b04d0ed9d5213b92dbcfe82fc76757828d663f0d26c90e"} +{"inputs":{"elapsed_hours":1},"n":29,"prev":"sha256:bf52ab4a75f5afed3ee83a1de9e415383d17b1d8f1f32c2609d1f8f1d8512fa3","stance":"sha256:1e2818165a797d75d25aefbe48a26317243d3d4650c78fc77df5c35d8d456411","state":"sha256:ce3d1daaec62f9a84cb18ff24c060fd2713a91fb2cb54f558f95f18804b98535"} +{"inputs":{"elapsed_hours":18,"praise":true},"n":30,"prev":"sha256:d7c454d66d42217c85ed00a2beed978962422a32e8c3c83e2e3f6436de1e2b08","stance":"sha256:91d8b623bf17c59d735566df0ee7f4218e01699d1678981745b6c1620f48506f","state":"sha256:16bb27aac722ce360e416a0b8326aa1bd1df3021cef6b3bc44b3b851345f03cb"} +{"inputs":{"elapsed_hours":1,"praise":true},"n":31,"prev":"sha256:246289cd37c69fda3297318055870ebfe733885e5fdc84d742e44149b7d7ee23","stance":"sha256:8e1c523e6fb9d1bb0d3398a8248037d893e62f9fe8e180bc769064e45b64290e","state":"sha256:b06b39138b5278f5377ff02daf9292868ea7a418887d758389c7bb9274f81e16"} +{"inputs":{"elapsed_hours":1,"loss":true,"praise":true},"n":32,"prev":"sha256:9ad62a2fec071ab334c2ad91115139c2f0ef009adde794a1de7e938cdd1d4b1f","stance":"sha256:a99407a5bd6e75b46325c270ececeb6418e4c6629595d084a3f26a312fa86378","state":"sha256:33b978509fb8b230859517d938e3b04b69298c049626b6babc2a06e97286a7c5"} +{"inputs":{"elapsed_hours":1},"n":33,"prev":"sha256:2fe58ee0cd0c8950654158a43cf99353bd0452e93daf1e3a84913cb3561777eb","stance":"sha256:f723371e15ac5be00f748ad3fd9c55c0a3674d97fe749a952732dfa4f012c9e0","state":"sha256:e747cb776c40d3a40a95547c71c7a9d3df198fa954263cec66a32e3478a00f34"} +{"inputs":{"elapsed_hours":1,"praise":true},"n":34,"prev":"sha256:7f64017cbe25a92edf425c10177f5018729a9a845fc1f1a9883c645589778e31","stance":"sha256:3c66ae14f8efc40864b0a99634eb936f2c135d047338321326c0fae1efa26c92","state":"sha256:48ef9d825d519f5a913ffa6375211e6ae61bb7609594af0a38bd2bd317639e7c"} +{"inputs":{"elapsed_hours":1,"loss":true,"praise":true},"n":35,"prev":"sha256:8e11035d8752b94e0003b4549ff7d28e57d18a26fc787347f8e81fd5f01cec43","stance":"sha256:b31673c0536b11d21665abc3e54083cb1129285ee37ea5ce37ed4ff3cd05c633","state":"sha256:2785f59db46a6a4841e3c621af23dfb4339da4da09f4d4d06f6e0b189c659447"} +{"inputs":{"elapsed_hours":1},"n":36,"prev":"sha256:fce57be368f3595d6151dcd7f56b16b4227db052009d63b1575d736c72fd2f23","stance":"sha256:0b162879cea5b19611b6a88f5d343e0d1fbb0f18eded338fc73dcb53ac843ae4","state":"sha256:868371e40095058acddf58c2d41db6d2b745ed39b4cd0da92394c84597df9d6c"} +{"inputs":{"elapsed_hours":18,"praise":true},"n":37,"prev":"sha256:2f99e6446142c5d6432c2cd030b2549d972d33c72ba3fd0b7a061dd0078dee4e","stance":"sha256:ca3543afa2b251e772278d2862ce89b5dd0d811aa44b1e2fc6bb2ac1ce020ea2","state":"sha256:d301e18cd2314755a42a5fea520f1b416c04bf70a74cd486c4e9724715710830"} +{"inputs":{"elapsed_hours":1,"praise":true},"n":38,"prev":"sha256:d75e93ec7e1ec197d9bcfa5e5c0ccbca04d85e5a51cc9488dec3a11138c9f83c","stance":"sha256:2347dacb375c7a488475c39c6c80a38eb2d33aa41c9008859b4c131aec1f6cd0","state":"sha256:e2e0261923f7f1f9e992df9cdb093174f61c889883319ba8d373c314fa98f815"} +{"inputs":{"elapsed_hours":1,"loss":true,"praise":true},"n":39,"prev":"sha256:0964d14cd4c6b9d247e4746b8a33ec81f5e547325ceb6cde06c29d971ef4c276","stance":"sha256:575dda9037188192ef0812eb784f3ac3c4b83f66d1389da50876246aa9a9bce7","state":"sha256:0e8aea2817ab948bd34dd113abfbaed491ebf69b12db357bbe8553ac0fe80379"} +{"inputs":{"elapsed_hours":1},"n":40,"prev":"sha256:365a7f88d74fcf6d043ca6ae61158e66754b07c1c1dfb38593e35a3c3dc84925","stance":"sha256:ed9df7b0c333da68b9b68d7902d55a8aaee11ae5fae6e6065625f368b3a08b7b","state":"sha256:246654662dc563007bcb121783cf448d1b667fb0dbdc1b8e75ad53aa079a4aa0"} +{"inputs":{"elapsed_hours":1,"praise":true},"n":41,"prev":"sha256:96203ca923867deed27d1796c2673606d34cc4e6a38c5926bf61093873fce23f","stance":"sha256:10ba7fab7c18543f79b836ab23389a2254e00c1ccbd3566c701a0ff179256d47","state":"sha256:9d0d3d8f2d0727a8530cede5bd78899928a03f3c4b9a420398c1da8e66d836e8"} +{"inputs":{"elapsed_hours":1,"loss":true,"praise":true},"n":42,"prev":"sha256:10e99fe58b5014da143025626cccf423af399008f43d41dcf95bf8cd2fceea4e","stance":"sha256:fbffc56dce65992631dd7bb08b4a5da83c4e09723becda0da687ed19d4863160","state":"sha256:5f6542f3d29d28d0062868cbf529229ee268141217229d925f381704aa868ea8"} +{"inputs":{"elapsed_hours":1},"n":43,"prev":"sha256:f0aeb90beef2450ac94d51b820d25c318be58de85b547b8b912104e3b2d5032f","stance":"sha256:cc0c50e35b17de0da4b0b491fb318562f53d850124683f258ce013cf788f937b","state":"sha256:8421541238c935bac96d37a1bec939bd47b37342c30ecef8f883c5829ad74eee"} +{"inputs":{"elapsed_hours":18,"praise":true},"n":44,"prev":"sha256:ab73e8838d1ae5ea1135c44196716c03aea6ccfc6c7865dfb8d966c6185193df","stance":"sha256:5ed36f932d8eba311468db37a7fe4a68c40deae818631efab0173f541865457c","state":"sha256:e16ff9a375b1b539e9e22a9220a33d5c5b0a94f5888d896299a9483fb72a4088"} +{"inputs":{"elapsed_hours":1,"praise":true},"n":45,"prev":"sha256:ac9ff476555aee6bf77ad4b4cde2b9e93ba1f2d296961cec4a04db86339eb23d","stance":"sha256:5274b3606f7fb71ed72440dac6c818d960a234bd0eddb9cb48581ff5d5d9c569","state":"sha256:55e43ab92a7333c50de74b0fca261866197555087c0d9e838bebf860a2eb17ad"} +{"inputs":{"elapsed_hours":1,"loss":true,"praise":true},"n":46,"prev":"sha256:5fcf131369401047ce93803ee815fd1a3134eebfabe78e6c7243cba58bc31727","stance":"sha256:2472ea49e20dcaf3b64b6ec28ad474e6ed0b4b58f7a93b9a3991f11ab5182cc1","state":"sha256:924eaeafa407b55fdd888ec8b741158f872f22d74869f939008e1ad09f984c00"} +{"inputs":{"elapsed_hours":1},"n":47,"prev":"sha256:494431acafaa157c16ce86526867d66d4115b821d6f3969fba9c40c50add864b","stance":"sha256:5ace32b7ceff490b26ef9f56ab3cf91b0057b5289af4458e834bbcda6d5d7a69","state":"sha256:6226dfad8d5dffa6c2fd3ad453d32205374086d89f06b138f7a3922223b4002a"} +{"inputs":{"elapsed_hours":1,"praise":true},"n":48,"prev":"sha256:57f0c0c5a1122b4f73163435240c67fe1eb8b5537c0b72d629213593ff3b2fc4","stance":"sha256:3e2585e8a26b5975d4b84b8f72d7ff54dbf98ccbaff904fe38c68d5ff63673b4","state":"sha256:8a0ae4cddf1d64dbe77fb7bede7be29bc6246b570b6c7d7bf807938fb7416df4"} +{"inputs":{"elapsed_hours":1,"loss":true,"praise":true},"n":49,"prev":"sha256:463bd4a6b9c43c44353884f08de06988d6bd031201cadd37275633d21b3942c4","stance":"sha256:91b33ab04d0ccf7073c8ec35faac8787f8e7103a08c9b4f6e44ba1c889ab3c1c","state":"sha256:c13f40ccdc6ba0c62a69003f8c550912488e09e152b92e9a3a6aa397d7f728d2"} +{"inputs":{"elapsed_hours":1},"n":50,"prev":"sha256:82b4ab5c3156f40e2b6fb3ea32315cdfa92d518b10ff36456e3edeefe25ad268","stance":"sha256:caa51b36732ca10768210c568df0b9b6fed5214023d7c8ea71c49e588ba05f79","state":"sha256:719c1d2641bc278e503906cc65b3501cc2d0693f160ea339993be1f44fa90e23"} diff --git a/probbit-cli/tests/monitor.rs b/probbit-cli/tests/monitor.rs new file mode 100644 index 0000000..7e28c70 --- /dev/null +++ b/probbit-cli/tests/monitor.rs @@ -0,0 +1,234 @@ +//! `probbit monitor` (docs/persona.md §5.8) end to end: help, one frame pinned byte for byte (--once --plain), the exit codes, +//! --follow picking up appended lines within a second and replaying a truncated strand from the start, and --serve (or --open): +//! the page, the event stream and the stance documents over HTTP on 127.0.0.1. +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::process::{Command, Stdio}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +/// The tutor's demo week (seed 2), as `probbit live examples/persona/tutor.yaml --seed 2 --demo week --strand` 0.7.0 wrote it +const WEEK: &str = "tests/fixtures/monitor/tutor-week.strand"; + +fn probbit(args: &[&str]) -> (i32, String, String) { + let o = Command::new(env!("CARGO_BIN_EXE_probbit")).args(args).env_remove("NO_COLOR").env_remove("PROBBIT_THEME").stdin(Stdio::null()).output().unwrap(); + (o.status.code().unwrap_or(-1), String::from_utf8_lossy(&o.stdout).into_owned(), String::from_utf8_lossy(&o.stderr).into_owned()) +} +fn tmp(tag: &str) -> String { std::env::temp_dir().join(format!("probbit-monitor-{tag}-{}.strand", std::process::id())).to_str().unwrap().to_string() } +/// A pinned frame: the file's text with `STRAND` standing for the strand's path (line endings as \n, whatever the checkout did) +fn pinned(name: &str, path: &str) -> String { std::fs::read_to_string(format!("tests/fixtures/monitor/{name}")).unwrap().replace("\r\n", "\n").replace("STRAND", path) } +/// The fixture's leading `n` lines (the header and n - 1 events) +fn lead(n: usize) -> String { std::fs::read_to_string(WEEK).unwrap().split_inclusive('\n').take(n).collect() } + +#[test] +fn monitor_has_help() { + for h in ["--help", "-h"] { + let (c, out, err) = probbit(&["monitor", h]); assert_eq!(c, 0, "{err}"); assert!(err.is_empty(), "{err}"); + assert!(out.starts_with("usage: probbit monitor STRAND"), "{out}"); + for f in ["--follow", "--once", "--plain", "--fps N", "--demo", "exit: 0", "live verify"] { assert!(out.contains(f), "monitor help lacks {f}: {out}"); } + } + let (c, out, _) = probbit(&["--help"]); assert_eq!(c, 0); assert!(out.contains("probbit monitor STRAND"), "the usage lists monitor"); +} + +/// --once --plain: one frame in plain ASCII, byte for byte; the last event of the week and a failure turn (event 18: loss and +/// praise lit, the bound habit in brackets, humour held at none) +#[test] +fn one_plain_frame_is_pinned() { + let (c, out, err) = probbit(&["monitor", WEEK, "--once", "--plain"]); assert_eq!(c, 0, "{err}"); + assert!(out.is_ascii(), "--plain draws in plain ASCII"); assert_eq!(out, pinned("tutor-week.once.txt", WEEK)); + let f = tmp("e18"); std::fs::write(&f, lead(19)).unwrap(); + let (c, out, err) = probbit(&["monitor", &f, "--once", "--plain"]); assert_eq!(c, 0, "{err}"); + assert_eq!(out, pinned("tutor-week-18.once.txt", &f)); + // without --plain and without a terminal: no colour, the same frame drawn with box characters + let (c, uni, _) = probbit(&["monitor", &f]); assert_eq!(c, 0); + assert!(!uni.contains('\x1b') && uni.contains("replay verified ✓") && uni.contains('█'), "{uni}"); + assert_eq!(uni.lines().count(), out.lines().count()); + let _ = std::fs::remove_file(&f); +} + +/// --demo --plain --once: the last frame of the tutor's week, the same as `live --demo week` writes it (the pinned frame of the +/// fixture, all but the footer, which says the week is in memory), the same on every run +#[test] +fn the_demo_is_the_week_live_writes() { + let (c, a, err) = probbit(&["monitor", "--demo", "--plain", "--once"]); assert_eq!(c, 0, "{err}"); + let (_, b, _) = probbit(&["monitor", "--demo", "--plain"]); assert_eq!(a, b, "deterministic; piped, the demo prints its last frame"); + let week = pinned("tutor-week.once.txt", WEEK); let (wl, al): (Vec<&str>, Vec<&str>) = (week.lines().collect(), a.lines().collect()); + assert_eq!(wl.len(), al.len()); assert_eq!(wl[..wl.len() - 1], al[..al.len() - 1]); + assert!(al[al.len() - 1].starts_with("strand: the demo week, in memory | written by probbit ") && al[al.len() - 1].ends_with("--seed 2 --demo week"), "{a}"); + for (args, msg) in [(vec!["monitor", WEEK, "--demo"], "give no STRAND"), (vec!["monitor", "--demo", "--follow"], "--follow does not apply")] { + let (c, _, err) = probbit(&args); assert_eq!(c, 2, "{args:?}"); assert!(err.contains(msg), "{args:?}: {err}"); + } +} + +/// 0 every line replays (an incomplete last line is said, not replayed); 1 a line differs: the header names it and the frame shows +/// the event before it; 2 a bad flag, an unreadable file, a file that is not a strand +#[test] +fn exit_codes() { + let week = std::fs::read_to_string(WEEK).unwrap().replace("\r\n", "\n"); + let mut lines: Vec = week.lines().map(String::from).collect(); + lines[12] = lines[12].replacen(r#""elapsed_hours":1"#, r#""elapsed_hours":2"#, 1); + let bent = tmp("bent"); std::fs::write(&bent, lines.join("\n") + "\n").unwrap(); + let (c, out, _) = probbit(&["monitor", &bent, "--once", "--plain"]); assert_eq!(c, 1, "{out}"); + let head = out.lines().next().unwrap(); assert!(head.contains("event 11 ") && head.contains("line 13 diverges: the stance differs"), "{head}"); + let (c, v, _) = probbit(&["live", "verify", &bent]); assert_eq!(c, 1); assert!(v.contains(r#""line":13"#), "the line verify names: {v}"); + let part = tmp("part"); std::fs::write(&part, lead(4) + &week.lines().nth(4).unwrap()[..40]).unwrap(); + let (c, out, err) = probbit(&["monitor", &part, "--once", "--plain"]); assert_eq!(c, 0, "{err}"); + assert!(out.contains("event 3 ") && out.contains("line 5 is incomplete (no newline at its end): not replayed"), "{out}"); + let bare = tmp("head"); std::fs::write(&bare, lead(1)).unwrap(); + let (c, out, _) = probbit(&["monitor", &bare, "--once", "--plain"]); assert_eq!(c, 0); assert!(out.contains("event 0 ") && out.contains("no events yet"), "{out}"); + let not = tmp("not"); std::fs::write(¬, "{\"hello\":1}\n").unwrap(); + for (args, msg) in [(vec!["monitor", ¬], "not a probbit strand"), (vec!["monitor", "no/such.strand"], "cannot read"), (vec!["monitor"], "the strand file comes before the flags"), + (vec!["monitor", WEEK, "--once", "--follow"], "give one of them"), (vec!["monitor", WEEK, "--fps", "0"], "--fps N, from 1 to 60"), (vec!["monitor", WEEK, "--nope"], "unknown argument")] { + let (c, out, err) = probbit(&args); assert_eq!((c, out.as_str()), (2, ""), "{args:?}: {err}"); assert!(err.contains(msg), "{args:?}: {err}"); + } + for f in [bent, part, bare, not] { 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(); } } +/// stdout of a running child, collected as it comes +fn collect(child: &mut std::process::Child) -> Arc> { + let buf = Arc::new(Mutex::new(String::new())); let b = buf.clone(); let mut out = child.stdout.take().unwrap(); + std::thread::spawn(move || { let mut chunk = [0u8; 4096]; while let Ok(n) = out.read(&mut chunk) { if n == 0 { break; } b.lock().unwrap().push_str(&String::from_utf8_lossy(&chunk[..n])); } }); + buf +} +/// Wait for `what` to appear in the output after byte `from` -> the time it took +fn wait(buf: &Arc>, from: usize, what: &str, limit: Duration) -> Duration { + let t = Instant::now(); + loop { if buf.lock().unwrap()[from..].contains(what) { return t.elapsed(); } + assert!(t.elapsed() < limit, "no {what:?} within {limit:?}: {}", &buf.lock().unwrap()[from..]); std::thread::sleep(Duration::from_millis(10)); } +} + +/// --follow: a line appended to the strand is replayed and drawn within a second; a truncated strand is replayed from the start, +/// with a warning +#[test] +fn follow_picks_up_appended_lines() { + let f = tmp("follow"); std::fs::write(&f, lead(4)).unwrap(); + let mut child = Reap(Command::new(env!("CARGO_BIN_EXE_probbit")).args(["monitor", &f, "--follow", "--plain"]).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap()); + let buf = collect(&mut child.0); + wait(&buf, 0, "| event 3 |", Duration::from_secs(20)); + for k in 5..=7 { + let at = buf.lock().unwrap().len(); + let line = lead(k)[lead(k - 1).len()..].to_string(); + std::io::Write::write_all(&mut std::fs::OpenOptions::new().append(true).open(&f).unwrap(), line.as_bytes()).unwrap(); + let took = wait(&buf, at, &format!("| event {} |", k - 1), Duration::from_secs(5)); + assert!(took < Duration::from_secs(1), "event {} drawn after {took:?}", k - 1); + } + let at = buf.lock().unwrap().len(); + std::fs::write(&f, lead(3)).unwrap(); + wait(&buf, at, "the strand shrank (truncated or rotated): replayed from the start", Duration::from_secs(5)); + wait(&buf, at, "| event 2 |", Duration::from_secs(5)); + // removed, then another individual's strand in its place (a rotation): said, and replayed from its header + let other = tmp("other"); let _ = std::fs::remove_file(&other); + let (c, _, e) = probbit(&["live", "../examples/persona/tutor.yaml", "--seed", "3", "--demo", "week", "--plain", "--strand", &other]); assert_eq!(c, 0, "{e}"); + let at = buf.lock().unwrap().len(); + std::fs::remove_file(&f).unwrap(); + wait(&buf, at, "the strand is gone (removed or rotated): waiting for it", Duration::from_secs(5)); + std::fs::rename(&other, &f).unwrap(); + wait(&buf, at, "| seed 3 |", Duration::from_secs(5)); + wait(&buf, at, "| event 50 |", Duration::from_secs(5)); + // renamed over it without a gap: a strand of another length, or another header at the top, starts the replay over + let (c, _, e) = probbit(&["live", "../examples/persona/tutor.yaml", "--seed", "4", "--demo", "week", "--plain", "--strand", &other]); assert_eq!(c, 0, "{e}"); + let at = buf.lock().unwrap().len(); + std::fs::rename(&other, &f).unwrap(); + wait(&buf, at, "| seed 4 |", Duration::from_secs(5)); + drop(child); let _ = std::fs::remove_file(&f); +} + +/// GET `path` from the monitor on 127.0.0.1:`port`, addressed to `host` -> (status line, headers, body) +fn get(port: u16, path: &str, host: &str) -> (String, String, String) { + let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap(); s.set_read_timeout(Some(Duration::from_secs(20))).unwrap(); + write!(s, "GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n").unwrap(); + let mut r = vec![]; s.read_to_end(&mut r).unwrap(); + let t = String::from_utf8_lossy(&r).into_owned(); + let (head, body) = t.split_once("\r\n\r\n").unwrap_or((&t, "")); + let (status, headers) = head.split_once("\r\n").unwrap_or((head, "")); + (status.to_string(), headers.to_string(), body.to_string()) +} +/// Read an event stream until it has carried `n` events in all -> (event, data) of each (comments, the heartbeats, aside) +fn stream(s: &mut TcpStream, buf: &mut String, n: usize) -> Vec<(String, String)> { + let t = Instant::now(); + loop { + let body = buf.split_once("\r\n\r\n").map_or("", |(_, b)| b); + let evs: Vec<(String, String)> = body.split("\n\n").filter(|b| b.contains("\ndata: ") && body.contains(&format!("{b}\n\n"))).map(|b| { + let f = |k: &str| b.lines().find_map(|l| l.strip_prefix(k)).unwrap_or("").to_string(); (f("event: "), f("data: ")) }).collect(); + if evs.len() >= n { return evs; } + assert!(t.elapsed() < Duration::from_secs(20), "{n} events within 20 s: {buf}"); + let mut chunk = [0u8; 8192]; let k = s.read(&mut chunk).unwrap(); assert!(k > 0, "the stream closed: {buf}"); buf.push_str(&String::from_utf8_lossy(&chunk[..k])); + } +} + +/// --serve: the URL is the line on stdout; GET / is the page; /doc/K is event K's stance document as `probbit live` printed +/// it, byte for byte; /events opens with the layout and the latest frame and pushes a frame for a line appended to the +/// strand; a request addressed to another host is refused; --open with a browser that does not start fails nothing; a port +/// in use, --once or a host flag exit 2 +#[test] +fn serve_answers_the_page_the_events_and_the_documents() { + let full = tmp("serve-full"); let f = tmp("serve"); for x in [&full, &f] { let _ = std::fs::remove_file(x); } + let mut live = Command::new(env!("CARGO_BIN_EXE_probbit")).args(["live", "../examples/persona/tutor.yaml", "--seed", "2", "--clock", "fixed", "--strand", &full]) + .stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap(); + live.stdin.take().unwrap().write_all(b"{\"praise\":true,\"elapsed_hours\":1}\n{\"loss\":true,\"elapsed_hours\":2}\n{\"elapsed_hours\":13}\n{\"error\":true,\"elapsed_hours\":0.5}\n").unwrap(); + let o = live.wait_with_output().unwrap(); assert!(o.status.success()); + let out = String::from_utf8(o.stdout).unwrap(); let docs: Vec<&str> = out.lines().collect(); assert_eq!(docs.len(), 4); + let text = std::fs::read_to_string(&full).unwrap(); let lines: Vec<&str> = text.split_inclusive('\n').collect(); + std::fs::write(&f, lines[..4].concat()).unwrap(); + let mut child = Reap(Command::new(env!("CARGO_BIN_EXE_probbit")).args(["monitor", &f, "--serve", "--port", "0", "--open"]).env("BROWSER", "probbit-test-no-such-browser") + .stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap()); + let mut url = String::new(); std::io::BufRead::read_line(&mut std::io::BufReader::new(child.0.stdout.take().unwrap()), &mut url).unwrap(); + let port: u16 = url.trim_end().strip_prefix("http://127.0.0.1:").and_then(|p| p.strip_suffix('/')).unwrap_or_else(|| panic!("the URL line: {url:?}")).parse().unwrap(); + let host = format!("127.0.0.1:{port}"); + let (st, h, page) = get(port, "/", &host); + assert_eq!(st, "HTTP/1.1 200 OK"); assert!(h.contains("Content-Type: text/html") && h.contains("connect-src 'self'"), "{h}"); + assert!(page.contains("probbit monitor") && page.contains("new EventSource('/events')") && page.len() < 40_000, "{} bytes", page.len()); + // the page fetches nothing from anywhere else: no URL in it but the SVG namespace + assert!(page.match_indices("http").all(|(i, _)| page[i..].starts_with("http://www.w3.org/2000/svg")), "a URL in the page"); + for k in 1..=3 { let (st, _, d) = get(port, &format!("/doc/{k}"), &host); assert_eq!((st.as_str(), d.as_str()), ("HTTP/1.1 200 OK", format!("{}\n", docs[k - 1]).as_str()), "doc {k}"); } + for p in ["/doc/0", "/doc/4", "/doc/x", "/nope"] { assert_eq!(get(port, p, &host).0, "HTTP/1.1 404 Not Found", "{p}"); } + assert_eq!(get(port, "/", &format!("localhost:{port}")).0, "HTTP/1.1 200 OK"); + for other in ["evil.example", "evil.example:80", "127.0.0.1"] { assert_eq!(get(port, "/doc/1", other).0, "HTTP/1.1 403 Forbidden", "{other}"); } + // the stream: the layout, then the latest frame (event 3); a line appended is pushed as a frame (event 4) + let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap(); s.set_read_timeout(Some(Duration::from_secs(20))).unwrap(); + write!(s, "GET /events HTTP/1.1\r\nHost: {host}\r\n\r\n").unwrap(); + let mut buf = String::new(); let evs = stream(&mut s, &mut buf, 2); + assert!(buf.starts_with("HTTP/1.1 200 OK\r\n") && buf.contains("Content-Type: text/event-stream"), "{buf}"); + assert_eq!((evs[0].0.as_str(), evs[1].0.as_str()), ("meta", "frame")); + assert!(evs[0].1.contains(r#""name":"Pip""#) && evs[0].1.contains(r#""id":"warmth""#), "{}", evs[0].1); + assert!(evs[1].1.contains(r#""n":3,"#) && evs[1].1.contains(&format!(r#""doc":{},"#, docs[2])) && evs[1].1.contains(r#""diverges":null"#), "{}", evs[1].1); + std::fs::OpenOptions::new().append(true).open(&f).unwrap().write_all(lines[4].as_bytes()).unwrap(); + let evs = stream(&mut s, &mut buf, 3); + assert_eq!(evs[2].0, "frame"); assert!(evs[2].1.contains(r#""n":4,"#) && evs[2].1.contains(&format!(r#""doc":{},"#, docs[3])), "{}", evs[2].1); + assert_eq!(get(port, "/doc/4", &host).2, format!("{}\n", docs[3])); + // the port is taken: exit 2 + let (c, _, err) = probbit(&["monitor", &f, "--serve", "--port", &port.to_string()]); assert_eq!(c, 2); assert!(err.contains(&format!("cannot listen on 127.0.0.1:{port}")), "{err}"); + let _ = child.0.kill(); let mut err = String::new(); child.0.stderr.take().unwrap().read_to_string(&mut err).unwrap(); drop(child); + assert!(err.contains("--open: no browser started"), "{err}"); + for (args, msg) in [(vec!["monitor", &f, "--serve", "--once"], "give one of them"), (vec!["monitor", &f, "--port", "8080"], "--port goes with --serve"), + (vec!["monitor", &f, "--serve", "--host", "0.0.0.0"], "binds 127.0.0.1"), (vec!["monitor", &f, "--serve", "--plain"], "does not apply")] { + let (c, out, err) = probbit(&args); assert_eq!((c, out.as_str()), (2, ""), "{args:?}: {err}"); assert!(err.contains(msg), "{args:?}: {err}"); + } + for x in [full, f] { let _ = std::fs::remove_file(x); } +} + +/// --open serves the page as --serve does, so `probbit monitor --demo --open` is the demo in the browser: the URL line, the +/// page, the stream's layout (the demo week, Pip) and its opening frame; a browser that does not start fails nothing +#[test] +fn open_serves_the_page_the_demo_too() { + let mut child = Reap(Command::new(env!("CARGO_BIN_EXE_probbit")).args(["monitor", "--demo", "--open"]).env("BROWSER", "probbit-test-no-such-browser") + .stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap()); + let mut url = String::new(); std::io::BufRead::read_line(&mut std::io::BufReader::new(child.0.stdout.take().unwrap()), &mut url).unwrap(); + let port: u16 = url.trim_end().strip_prefix("http://127.0.0.1:").and_then(|p| p.strip_suffix('/')).unwrap_or_else(|| panic!("the URL line: {url:?}")).parse().unwrap(); + let host = format!("127.0.0.1:{port}"); + let (st, _, page) = get(port, "/", &host); assert_eq!(st, "HTTP/1.1 200 OK"); assert!(page.contains("probbit monitor")); + let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap(); s.set_read_timeout(Some(Duration::from_secs(20))).unwrap(); + write!(s, "GET /events HTTP/1.1\r\nHost: {host}\r\n\r\n").unwrap(); + let mut buf = String::new(); let evs = stream(&mut s, &mut buf, 2); + assert_eq!((evs[0].0.as_str(), evs[1].0.as_str()), ("meta", "frame")); + assert!(evs[0].1.contains(r#""demo":true"#) && evs[0].1.contains(r#""name":"Pip""#), "{}", evs[0].1); + let _ = child.0.kill(); let mut err = String::new(); child.0.stderr.take().unwrap().read_to_string(&mut err).unwrap(); drop(child); + assert!(err.contains("--open: no browser started"), "{err}"); + for (args, msg) in [(vec!["monitor", "--demo", "--open", "--once"], "--once draws one frame and --open keeps serving"), + (vec!["monitor", WEEK, "--open", "--fps", "5"], "monitor --open: --fps does not apply"), (vec!["monitor", "--demo", "--port", "8080"], "--port goes with --serve (or --open)")] { + let (c, out, err) = probbit(&args); assert_eq!((c, out.as_str()), (2, ""), "{args:?}: {err}"); assert!(err.contains(msg), "{args:?}: {err}"); + } +}