Skip to Content
ftui-runtimeEvidence sink

Evidence sink

FrankenTUI’s runtime makes non-trivial decisions continuously — should this resize be coalesced? should this frame use a full redraw or a dirty-row diff? is the frame budget about to be exceeded? — and the intelligence layer makes many more (BOCPD, VOI, conformal). Each one emits a JSONL evidence line to a single shared sink so operators can reconstruct exactly why the runtime did what it did.

File: crates/ftui-runtime/src/evidence_sink.rs.

Schema

crates/ftui-runtime/src/evidence_sink.rs
pub const EVIDENCE_SCHEMA_VERSION: &str = "ftui-evidence-v1"; pub const DEFAULT_MAX_EVIDENCE_BYTES: u64 = 50 * 1024 * 1024; // 50 MiB

Every line is a self-contained JSON object with at least:

FieldMeaning
schema_version"ftui-evidence-v1"
timestamp_isoISO-8601 with millisecond precision
domainWhat emitted the line (see table below)
verdictThe decision the policy reached
reasonA stable, lower-snake-case reason code

Domain-specific fields follow — prev_size, new_size, bucket, posterior, etc. The union of known domains lives in operations/evidence-grep-patterns.

Example line:

{ "schema_version": "ftui-evidence-v1", "timestamp_iso": "2026-04-23T12:34:56.789Z", "domain": "resize_decision", "verdict": "COALESCE", "reason": "rapid_resize_burst", "screen_id": "main", "prev_size": [80, 24], "new_size": [80, 25], "coalesce_count": 3 }

Configuration

crates/ftui-runtime/src/evidence_sink.rs
pub enum EvidenceSinkDestination { Stdout, File(PathBuf), } pub struct EvidenceSinkConfig { pub enabled: bool, pub destination: EvidenceSinkDestination, pub flush_on_write: bool, pub max_bytes: u64, // 0 = unlimited; default 50 MiB }

Three one-liner builders cover almost everything:

EvidenceSinkConfig::disabled(); // default EvidenceSinkConfig::enabled_stdout(); EvidenceSinkConfig::enabled_file("evidence.jsonl");

Fine-grained tuning:

EvidenceSinkConfig::default() .with_enabled(true) .with_destination(EvidenceSinkDestination::file("/tmp/ftui.jsonl")) .with_flush_on_write(true) // prefer true in tests .with_max_bytes(10 * 1024 * 1024);

Attach it to the program:

let config = ProgramConfig::default() .with_evidence_sink(EvidenceSinkConfig::enabled_file("evidence.jsonl"));

Size-cap semantics

File sinks enforce max_bytes. Key invariants (proved by tests in the same file):

  1. Existing file size counts. If you restart the process, the cap covers the pre-existing file too. EvidenceSink::from_config reads fs::metadata(path).len() and seeds the internal counter.
  2. Already-oversized files are write-locked. If the file is larger than the cap at open time, the sink is returned in a pre-capped state and every write becomes a silent drop.
  3. Drops are silent. Once capped, write_jsonl returns Ok(()) without writing — callers are never forced to handle “my diagnostic log is full” as an error.
  4. max_bytes == 0 disables the cap. Use on stdout or when an external log rotator owns the file.

Stdout sinks do not enforce the cap.

Ordering & concurrency

Writes acquire a std::sync::Mutex behind the sink before writing the line and (optionally) flushing. That guarantees:

  • Line ordering is deterministic with respect to the order of write_jsonl calls.
  • Lines never interleave with each other.

A second EvidenceSink is a .clone() away — internally it shares the same Arc<Mutex<_>>, so multiple threads can hold a sink handle without racing.

How lines are generated

The runtime itself does not format JSON; each subsystem owns its own serializer and calls sink.write_jsonl(line). Producers include:

SubsystemDomain(s)Explanation page
Resize coalescerresize_decision/intelligence/change-detection/bocpd
Diff strategydiff_decision/intelligence/bayesian-inference/diff-strategy
Frame budgetbudget_decision/intelligence/control-theory
Conformal predictorconformal/intelligence/conformal-prediction/vanilla
VOI samplervoi/intelligence/voi-sampling
Command palettepalette/intelligence/bayesian-inference/command-palette-ledger
Height predictionheight/intelligence/bayesian-inference/height-prediction
Cascade / degradationcascade/operations/frame-budget
Shadow runshadow/runtime/rollout/shadow-run

Grep recipes for debugging

These patterns have stabilized into a shared vocabulary (.verdict codes are uppercase; .reason codes are lower_snake_case):

# Resize coalescing events in the last 1000 lines tail -n 1000 evidence.jsonl | grep '"domain":"resize_decision"' # Diff strategy flips (FullRedraw <-> DirtyRows) grep '"domain":"diff_decision"' evidence.jsonl | jq -r '.verdict' | uniq -c # Any shadow-run divergence grep '"divergence":true' evidence.jsonl # Any rollout NO-MATCH verdict grep '"verdict":"NOMATCH"' evidence.jsonl # Frame-budget breaches (conformal or budget) grep -E '"(budget|conformal)_decision"' evidence.jsonl | grep BREACH # Effect queue backpressure grep '"queue_dropped"' evidence.jsonl | wc -l # Panics surfaced by the effect system grep '"panicked":true' evidence.jsonl

For richer slicing install jq and pipe:

jq -c 'select(.domain=="resize_decision" and .verdict=="COALESCE")' evidence.jsonl \ | jq -s 'length'

Worked example

examples/with_evidence.rs
use ftui::prelude::*; use ftui_runtime::EvidenceSinkConfig; fn main() -> std::io::Result<()> { let cfg = ProgramConfig::fullscreen() .with_evidence_sink( EvidenceSinkConfig::enabled_file("evidence.jsonl") .with_max_bytes(10 * 1024 * 1024), ); Program::with_config(MyModel::new(), cfg)?.run() }

Run, reproduce the bug, quit, then:

head -n 20 evidence.jsonl grep '"domain":"budget_decision"' evidence.jsonl | tail -n 5

Pitfalls

Stdout sinks collide with inline mode. In inline mode the runtime already writes scrollback through TerminalWriter; pointing an evidence sink at stdout from the same process will interleave JSON lines with your UI frames. Use a file, FTUI_EVIDENCE_FILE, or redirect a descriptor.

Silent drops are by design, not a bug. If you need backpressure on observability, set max_bytes generously and rotate externally. Never attempt to infer liveness from evidence-line count once the cap is in sight.

Cross-references