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
pub const EVIDENCE_SCHEMA_VERSION: &str = "ftui-evidence-v1";
pub const DEFAULT_MAX_EVIDENCE_BYTES: u64 = 50 * 1024 * 1024; // 50 MiBEvery line is a self-contained JSON object with at least:
| Field | Meaning |
|---|---|
schema_version | "ftui-evidence-v1" |
timestamp_iso | ISO-8601 with millisecond precision |
domain | What emitted the line (see table below) |
verdict | The decision the policy reached |
reason | A 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
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):
- Existing file size counts. If you restart the process, the cap
covers the pre-existing file too.
EvidenceSink::from_configreadsfs::metadata(path).len()and seeds the internal counter. - 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.
- Drops are silent. Once capped,
write_jsonlreturnsOk(())without writing — callers are never forced to handle “my diagnostic log is full” as an error. max_bytes == 0disables 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_jsonlcalls. - 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:
| Subsystem | Domain(s) | Explanation page |
|---|---|---|
| Resize coalescer | resize_decision | /intelligence/change-detection/bocpd |
| Diff strategy | diff_decision | /intelligence/bayesian-inference/diff-strategy |
| Frame budget | budget_decision | /intelligence/control-theory |
| Conformal predictor | conformal | /intelligence/conformal-prediction/vanilla |
| VOI sampler | voi | /intelligence/voi-sampling |
| Command palette | palette | /intelligence/bayesian-inference/command-palette-ledger |
| Height prediction | height | /intelligence/bayesian-inference/height-prediction |
| Cascade / degradation | cascade | /operations/frame-budget |
| Shadow run | shadow | /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.jsonlFor richer slicing install jq and pipe:
jq -c 'select(.domain=="resize_decision" and .verdict=="COALESCE")' evidence.jsonl \
| jq -s 'length'Worked example
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 5Pitfalls
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.