//! Pure tests for the phase-5 auditor and its O5 metrics. //! //! Two things are being tested here and they are worth keeping distinct: //! //! - **Audit-layer behaviour** — the fan-out gate, record suppression, sequence //! accounting, epoch reporting, and above all that every record carries the //! *complete* candidate universe (§5.1). These are properties nothing else //! tests, because nothing else exists at this layer. //! - **A few §5.1 matrix shapes in fixture form** — row 1 (owner-bridge //! forwarder), row 3 (two modules, one tainted), row 12 (AEC lifecycle). These //! are *not* re-litigating phase 2, whose 57 tests already own those verdicts. //! They exist so that a plumbing mistake between the engine and the record — //! a dropped reason code, an inverted partition — fails here, at compile-time //! speed, rather than only in front of a live daemon. //! //! The live half of the gate cannot live in this file by definition: a fixture //! tests my model against my own assumptions, and §5's whole argument is that //! only a live run tests my model against PipeWire. See the matrix runs recorded //! in the phase-5 results file. use super::metrics::{BUCKET_LABELS, Metrics, QUEUE_THRESHOLD_US, Sample}; use super::*; use crate::host::aec::AecConfig; use crate::host::observer::{EventKind, Readiness}; use crate::host::taint::fixture::{self, Graph, NodeRef}; use crate::host::taint::snapshot::{GraphSnapshot, MediaRole}; const AEC_MODULE: u64 = 7; const TIMEOUT: Millis = 5_000; fn ready(snapshot: GraphSnapshot) -> Projection { Projection { snapshot, pipewire_pulse_pid: Some(fixture::PULSE_PID), graph_ready: true, readiness: Readiness::Complete, } } fn not_ready(snapshot: GraphSnapshot, readiness: Readiness) -> Projection { Projection { snapshot, pipewire_pulse_pid: Some(fixture::PULSE_PID), graph_ready: false, readiness, } } fn auditor_off() -> Auditor { Auditor::new(AuditConfig { aec: AecConfig::Off, aec_timeout: TIMEOUT, }) } fn auditor_aec(index: u64) -> Auditor { Auditor::new(AuditConfig { aec: AecConfig::PulseModule(index), aec_timeout: TIMEOUT, }) } /// One graph-triggered recompute at `now`. fn observe(auditor: &mut Auditor, projection: &Projection, now: Millis) -> AuditOutcome { auditor.observe(projection, EventKind::Graph, now) } /// Candidate names split into (eligible, excluded-with-reason), which is how the /// §5.1 rows are phrased. Names rather than serials so a failure reads as the /// scenario rather than as an integer. fn partition(body: &AuditBody) -> (Vec<&str>, Vec<(&str, &str)>) { let eligible = body .candidates .iter() .filter(|row| row.eligible) .map(|row| row.name.as_deref().unwrap_or("")) .collect(); let excluded = body .candidates .iter() .filter(|row| !row.eligible) .map(|row| { ( row.name.as_deref().unwrap_or(""), row.reason.unwrap_or(""), ) }) .collect(); (eligible, excluded) } // ── the §5.1 structural requirement ─────────────────────────────────────── /// The record must contain **every** `Stream/Output/Audio` node, not only the /// interesting ones. This is the property the whole exact-partition requirement /// rests on: if the record could omit a candidate, then asserting a complete /// partition over the record would still not constrain the graph. #[test] fn the_record_carries_the_complete_candidate_universe() { let mut graph = Graph::new(); graph.app_node("music", MediaRole::StreamOutput, 100); graph.app_node("game", MediaRole::StreamOutput, 101); graph.app_node("recorder", MediaRole::StreamInput, 102); graph.device_node("speakers", MediaRole::Sink); let projection = ready(graph.build()); let outcome = observe(&mut auditor_off(), &projection, 0); let (eligible, excluded) = partition(&outcome.record.body); // Both playback streams, neither the capture stream nor the sink. Ordered by // serial (creation order), which is what makes the partition assertions in // every other row stable rather than dependent on a hash iteration. assert_eq!(eligible, vec!["music", "game"]); assert!(excluded.is_empty(), "unexpected exclusions: {excluded:?}"); assert_eq!(outcome.record.body.candidates.len(), 2); assert_eq!(outcome.record.body.eligible_count, 2); assert_eq!(outcome.record.body.excluded_count, 0); } /// The fail-closed default asserted at the boundary (impl plan §4, phase 2's /// "one addition"): nothing in, nothing eligible — and, just as importantly, no /// panic and no invented row. #[test] fn an_empty_graph_yields_an_empty_partition() { let projection = ready(Graph::new().build()); let outcome = observe(&mut auditor_off(), &projection, 0); assert!(outcome.record.body.candidates.is_empty()); assert!(outcome.record.body.taint.is_empty()); assert_eq!(outcome.record.body.eligible_count, 0); assert_eq!(outcome.record.body.excluded_count, 0); assert!(outcome.record.body.fan_out_permitted); } /// `eligible_count + excluded_count` is the candidate count, always. A partition /// that does not partition would let a row's two assertions both pass while the /// record described no coherent state. #[test] fn the_counts_always_partition_the_candidates() { let mut graph = Graph::new(); graph.app_node("music", MediaRole::StreamOutput, 100); graph.peerspeak_node("peerspeak-playback", 200); let projection = ready(graph.build()); let body = observe(&mut auditor_off(), &projection, 0).record.body; assert_eq!( body.eligible_count + body.excluded_count, body.candidates.len() ); assert_eq!(body.eligible().len(), body.eligible_count); assert_eq!(body.excluded().len(), body.excluded_count); } /// Every excluded row names a reason and every eligible row does not. The /// §5.1 rows assert "excluded, with reason code" — a `None` reason on an /// excluded row would make that assertion unwritable. #[test] fn reason_presence_is_exactly_the_exclusion() { let mut graph = Graph::new(); graph.app_node("music", MediaRole::StreamOutput, 100); graph.peerspeak_node("peerspeak-playback", 200); let projection = ready(graph.build()); for row in observe(&mut auditor_off(), &projection, 0) .record .body .candidates { assert_eq!( row.eligible, row.reason.is_none(), "row {row:?} has eligibility and reason out of step" ); } } // ── readiness ───────────────────────────────────────────────────────────── /// No decision made from a partial graph is a decision. Note this is asserted on /// the *eligible* half too: an implementation that reported nothing at all while /// not ready would also be wrong, because the audit must still show what it can /// see. #[test] fn a_not_ready_graph_excludes_every_candidate() { let mut graph = Graph::new(); graph.app_node("music", MediaRole::StreamOutput, 100); graph.app_node("game", MediaRole::StreamOutput, 101); let projection = not_ready(graph.build(), Readiness::Waiting); let body = observe(&mut auditor_off(), &projection, 0).record.body; let (eligible, excluded) = partition(&body); assert!(eligible.is_empty()); assert_eq!( excluded, vec![("music", "graph-not-ready"), ("game", "graph-not-ready"),] ); assert!(!body.graph_ready); } /// The three ways `graph_ready` can be false are distinguishable in the record. /// Collapsing them would make a timed-out observer — a fail-closed *fault* — /// indistinguishable from an enumeration that is merely still running. #[test] fn the_epoch_distinguishes_the_ways_a_graph_can_be_unready() { let mut graph = Graph::new(); graph.app_node("music", MediaRole::StreamOutput, 100); let snapshot = graph.build(); for (readiness, expected) in [ (Readiness::Waiting, "waiting"), (Readiness::TimedOut, "timed-out"), // A completed epoch momentarily blocked on a current obligation: the // interesting one, because `graph_ready` alone makes it look like a // brand-new observer. (Readiness::Complete, "complete"), ] { let projection = not_ready(snapshot.clone(), readiness); let body = observe(&mut auditor_off(), &projection, 0).record.body; assert_eq!(body.epoch, expected); assert!(!body.graph_ready); } let body = observe(&mut auditor_off(), &ready(snapshot), 0).record.body; assert_eq!(body.epoch, "complete"); assert!(body.graph_ready); } // ── the fan-out gate (phase 4 → audit) ──────────────────────────────────── /// `--aec=off` leaves the gate open: `NotConfigured` is "there is no echo /// canceller", not "we failed to find one". #[test] fn aec_off_leaves_the_gate_open() { let mut graph = Graph::new(); graph.app_node("music", MediaRole::StreamOutput, 100); let projection = ready(graph.build()); let body = observe(&mut auditor_off(), &projection, 0).record.body; assert_eq!(body.aec_state, "not-configured"); assert!(body.fan_out_permitted); assert_eq!(body.gate_reason, None); assert_eq!(body.aec_module_id, None); assert_eq!(partition(&body).0, vec!["music"]); } /// While the configured identity has not been seen, nothing may fan out — /// silence over echo — and the record says why in a code, not in prose. #[test] fn a_validating_gate_excludes_every_engine_eligible_candidate() { let mut graph = Graph::new(); graph.app_node("music", MediaRole::StreamOutput, 100); graph.app_node("game", MediaRole::StreamOutput, 101); let projection = ready(graph.build()); let body = observe(&mut auditor_aec(AEC_MODULE), &projection, 0) .record .body; let (eligible, excluded) = partition(&body); assert_eq!(body.aec_state, "validating"); assert!(!body.fan_out_permitted); assert_eq!(body.gate_reason, Some("aec-validating")); assert!(eligible.is_empty()); assert_eq!( excluded, vec![("music", "aec-validating"), ("game", "aec-validating")] ); } /// A shut gate must not erase the engine's own reason codes. If it did, every /// §5.1 row run under a shut gate would report one uniform code and the matrix /// would stop constraining the taint engine at all — the record would say /// "nothing may fan out" while hiding *which* nodes were tainted and how. #[test] fn a_shut_gate_preserves_the_engines_own_reasons() { let mut graph = Graph::new(); graph.app_node("music", MediaRole::StreamOutput, 100); graph.peerspeak_node("peerspeak-playback", 200); let projection = ready(graph.build()); let body = observe(&mut auditor_aec(AEC_MODULE), &projection, 0) .record .body; let (_, excluded) = partition(&body); assert!(!body.fan_out_permitted); assert_eq!( excluded, vec![ ("music", "aec-validating"), // Tagged, so it keeps the reason that actually applies to it. ("peerspeak-playback", "peerspeak-owned"), ] ); } /// The deadline is armed on the first ready graph, so a slow enumeration reads /// as "unknown", not "absent" (the phase-4 user design call). Past it with the /// identity never seen, the gate latches shut. #[test] fn the_gate_fails_closed_after_the_deadline() { let mut graph = Graph::new(); graph.app_node("music", MediaRole::StreamOutput, 100); let snapshot = graph.build(); let mut auditor = auditor_aec(AEC_MODULE); // Still enumerating well past the timeout: not a failure, because absence // has not been established. let waiting = not_ready(snapshot.clone(), Readiness::Waiting); let body = observe(&mut auditor, &waiting, TIMEOUT * 3).record.body; assert_eq!(body.aec_state, "validating"); // Ready arms the deadline; the clock has to advance past it from here. let projection = ready(snapshot); let body = observe(&mut auditor, &projection, TIMEOUT * 3).record.body; assert_eq!(body.aec_state, "validating"); let body = observe(&mut auditor, &projection, TIMEOUT * 6 + 1) .record .body; assert_eq!(body.aec_state, "failed"); assert_eq!(body.gate_reason, Some("aec-failed")); assert_eq!(partition(&body).1, vec![("music", "aec-failed")]); } // ── §5.1 row 12: the AEC lifecycle ──────────────────────────────────────── /// Build the four nodes `module-echo-cancel` creates, all bearing one index: /// two `Stream/*` legs plus the virtual sink/source pair (v3.4 §5.2). The /// playback leg is the hazard — a `Stream/Output/Audio` wired to the speakers. fn aec_nodes(graph: &mut Graph, index: u64) -> Vec { vec![ graph.module_node("echo-cancel-playback", MediaRole::StreamOutput, index), graph.module_node("echo-cancel-capture", MediaRole::StreamInput, index), graph.module_node("echo-cancel-sink", MediaRole::Sink, index), graph.module_node("echo-cancel-source", MediaRole::Source, index), ] } /// §5.1 row 12: AEC loaded → validated, its playback leg excluded by identity /// while everything else stays eligible → unloaded → `Revoked`, gate shut. /// /// The eligible half is the load-bearing assertion in the first phase: an /// implementation that excluded the whole graph the moment an AEC appeared would /// satisfy "the four nodes are excluded" and still be wrong. #[test] fn row_12_aec_loaded_then_unloaded_validates_then_revokes() { let mut graph = Graph::new(); graph.app_node("music", MediaRole::StreamOutput, 100); let aec = aec_nodes(&mut graph, AEC_MODULE); let mut auditor = auditor_aec(AEC_MODULE); let loaded = ready(graph.build()); let body = observe(&mut auditor, &loaded, 0).record.body; let (eligible, excluded) = partition(&body); assert_eq!(body.aec_state, "validated"); assert!(body.fan_out_permitted); assert_eq!(body.aec_module_id, Some(AEC_MODULE)); assert_eq!(eligible, vec!["music"]); assert_eq!(excluded, vec![("echo-cancel-playback", "aec-identity")]); // Every node bearing the index goes away: a real unload. let unloaded = ready(graph.build_without(&aec)); let body = observe(&mut auditor, &unloaded, 1).record.body; let (eligible, excluded) = partition(&body); assert_eq!(body.aec_state, "revoked"); assert!(!body.fan_out_permitted); assert_eq!(body.gate_reason, Some("aec-revoked")); assert_eq!(body.aec_module_id, None); assert!(eligible.is_empty()); assert_eq!(excluded, vec![("music", "aec-revoked")]); } /// One leg corking is not a revocation (v3.4 §5.3). Getting this wrong turns an /// ordinary cork into a share-wide audio stop, so the audit must report the /// identity as still live. #[test] fn row_12_partial_leg_loss_does_not_revoke() { let mut graph = Graph::new(); graph.app_node("music", MediaRole::StreamOutput, 100); let aec = aec_nodes(&mut graph, AEC_MODULE); let mut auditor = auditor_aec(AEC_MODULE); let body = observe(&mut auditor, &ready(graph.build()), 0).record.body; assert_eq!(body.aec_state, "validated"); // The capture leg alone disappears; three nodes still bear the index. let partial = ready(graph.build_without(&aec[1..2])); let body = observe(&mut auditor, &partial, 1).record.body; assert_eq!(body.aec_state, "validated"); assert!(body.fan_out_permitted); assert_eq!(partition(&body).0, vec!["music"]); } /// Revocation is sticky terminal: module indices are reused verbatim across an /// unload/reload (v3.4 §5.2 correction 3), so a reappearing index must not /// resurrect the epoch and alias onto an unrelated module. A genuine reload gets /// a fresh validator, never this one. #[test] fn row_12_a_reused_index_does_not_resurrect_a_revoked_epoch() { let mut graph = Graph::new(); graph.app_node("music", MediaRole::StreamOutput, 100); let aec = aec_nodes(&mut graph, AEC_MODULE); let mut auditor = auditor_aec(AEC_MODULE); observe(&mut auditor, &ready(graph.build()), 0); let unloaded = graph.build_without(&aec); let body = observe(&mut auditor, &ready(unloaded), 1).record.body; assert_eq!(body.aec_state, "revoked"); // A second module comes back with the same index — different objects, // identical number. let mut reloaded = Graph::new(); reloaded.app_node("music", MediaRole::StreamOutput, 100); aec_nodes(&mut reloaded, AEC_MODULE); let body = observe(&mut auditor, &ready(reloaded.build()), 2) .record .body; assert_eq!(body.aec_state, "revoked"); assert!(!body.fan_out_permitted); assert_eq!(body.gate_reason, Some("aec-revoked")); } // ── §5.1 rows in fixture form (plumbing, not phase-2 verdicts) ──────────── /// §5.1 row 1: a `module-null-sink` + `module-loopback` forwarder. The output /// leg is excluded across the **owner bridge** — naming the mechanism, not a /// link walk — while an identically-shaped forwarder with no tainted input stays /// eligible. The second half is what an exclude-everything build fails. #[test] fn row_1_owner_bridge_forwarder_with_an_untainted_control() { let mut graph = Graph::new(); // Tainted root: peerspeak's own call playback, feeding a sink the forwarder // reads back out. let call = graph.peerspeak_node("peerspeak-call", 200); let sink = graph.module_node("tainted-null-sink", MediaRole::Sink, 30); graph.link(call, sink); let capture = graph.module_node("tainted-loopback-capture", MediaRole::StreamInput, 30); let playback = graph.module_node("tainted-loopback-playback", MediaRole::StreamOutput, 30); graph.link(sink, capture); let _ = playback; // Control: the same shape, fed by nothing tainted. let clean_sink = graph.module_node("clean-null-sink", MediaRole::Sink, 31); let clean_capture = graph.module_node("clean-loopback-capture", MediaRole::StreamInput, 31); let clean_playback = graph.module_node("clean-loopback-playback", MediaRole::StreamOutput, 31); graph.link(clean_sink, clean_capture); let _ = clean_playback; let body = observe(&mut auditor_off(), &ready(graph.build()), 0) .record .body; let (eligible, excluded) = partition(&body); assert_eq!(eligible, vec!["clean-loopback-playback"]); assert_eq!( excluded, vec![ ("peerspeak-call", "peerspeak-owned"), ("tainted-loopback-playback", "tainted-owner-bridge"), ] ); } /// §5.1 row 3: two Pulse modules, one tainted input. **The other module's output /// must be eligible** — this is the row that makes a wrong pipewire-pulse-PID /// fusion observable, because fusing all Pulse-created nodes into one owner /// would drag the innocent module's output leg down with the tainted one. #[test] fn row_3_one_tainted_module_does_not_taint_the_other() { let mut graph = Graph::new(); let call = graph.peerspeak_node("peerspeak-call", 200); let sink = graph.module_node("null-sink-a", MediaRole::Sink, 40); graph.link(call, sink); let capture_a = graph.module_node("module-a-capture", MediaRole::StreamInput, 40); let playback_a = graph.module_node("module-a-playback", MediaRole::StreamOutput, 40); graph.link(sink, capture_a); let _ = playback_a; // A second, entirely independent module reading an untainted source. let mic = graph.device_node("microphone", MediaRole::Source); let capture_b = graph.module_node("module-b-capture", MediaRole::StreamInput, 41); let playback_b = graph.module_node("module-b-playback", MediaRole::StreamOutput, 41); graph.link(mic, capture_b); let _ = playback_b; let body = observe(&mut auditor_off(), &ready(graph.build()), 0) .record .body; let (eligible, excluded) = partition(&body); assert_eq!(eligible, vec!["module-b-playback"]); assert_eq!( excluded, vec![ ("peerspeak-call", "peerspeak-owned"), ("module-a-playback", "tainted-owner-bridge"), ] ); } /// §5.1 row 7 (cycle prevention, v3.4 §6.2): a forwarder reading *another* /// pixelpass host's capture sink must be excluded by its **named output /// serial**, or two hosts sharing to each other build an audio cycle. #[test] fn row_7_a_forwarder_reading_another_hosts_capture_sink_is_excluded() { let mut graph = Graph::new(); // The other host's own client, in *this* graph — a node pointing at a client // that does not exist would exercise the unresolved-owner path instead of the // capture-sink-name path this row is about. let other_client = graph.client(Some(fixture::PULSE_PID)); let other_sink = graph.node( "pixelpass_capture_deadbeef", MediaRole::Sink, fixture::app(other_client, 300), ); let capture = graph.module_node("cycle-loopback-capture", MediaRole::StreamInput, 50); let playback = graph.module_node("cycle-loopback-playback", MediaRole::StreamOutput, 50); graph.link(other_sink, capture); let _ = playback; graph.app_node("music", MediaRole::StreamOutput, 100); let body = observe(&mut auditor_off(), &ready(graph.build()), 0) .record .body; let (eligible, excluded) = partition(&body); assert_eq!(eligible, vec!["music"]); assert_eq!( excluded, vec![("cycle-loopback-playback", "tainted-owner-bridge")] ); // The sink itself is tainted, by the mechanism that names it. let sink_taint = body .taint .iter() .find(|row| row.name.as_deref() == Some("pixelpass_capture_deadbeef")) .expect("the other host's capture sink must be tainted"); assert_eq!(sink_taint.reason, "pixelpass-owned"); } /// Sticky taint (§5.1 row 10) is reported as sticky, not silently folded into /// an ordinary exclusion. The flag is how the audit distinguishes "this is /// tainted right now" from "this was tainted and its owner has not fully torn /// down" — two different things to be surprised by. #[test] fn sticky_exclusions_are_flagged_as_sticky() { let mut graph = Graph::new(); let call = graph.peerspeak_node("peerspeak-call", 200); let sink = graph.module_node("null-sink", MediaRole::Sink, 60); graph.link(call, sink); let capture = graph.module_node("loopback-capture", MediaRole::StreamInput, 60); graph.module_node("loopback-playback", MediaRole::StreamOutput, 60); graph.link(sink, capture); let mut auditor = auditor_off(); let body = observe(&mut auditor, &ready(graph.build()), 0).record.body; let playback = body .row_named("loopback-playback") .expect("the output leg must be a candidate"); assert!(!playback.eligible); assert!(!playback.sticky, "first sight is not sticky"); // The tainted input leg goes away; the output leg lives on. let body = observe(&mut auditor, &ready(graph.build_without(&[capture])), 1) .record .body; let playback = body .row_named("loopback-playback") .expect("the output leg must still be a candidate"); assert!(!playback.eligible); assert!(playback.sticky, "the taint is carried over, and says so"); } // ── record accounting ───────────────────────────────────────────────────── /// Ticks exist to drive the AEC deadline, not to describe the graph. Emitting an /// identical record four times a second would bury the graph events the audit /// exists to show — but a tick that *does* change something must still be /// emitted, or a `Validating → Failed` transition (which only a tick can cause) /// would never appear in the log at all. #[test] fn an_unchanged_tick_is_suppressed_but_a_changed_one_is_not() { let mut graph = Graph::new(); graph.app_node("music", MediaRole::StreamOutput, 100); let projection = ready(graph.build()); let mut auditor = auditor_aec(AEC_MODULE); assert!(auditor.observe(&projection, EventKind::Graph, 0).emit); assert!( !auditor.observe(&projection, EventKind::Tick, 100).emit, "an identical tick record is noise" ); assert!( !auditor.observe(&projection, EventKind::Tick, 200).emit, "still noise" ); // The deadline expires on a tick: the state changes, so this one is emitted. let outcome = auditor.observe(&projection, EventKind::Tick, TIMEOUT + 1); assert!(outcome.emit); assert_eq!(outcome.record.body.aec_state, "failed"); } /// A graph event always emits, even when the decision content is identical — a /// suppressed graph event would erase the evidence that the graph changed at all, /// and "PipeWire told us something and nothing moved" is itself a finding. #[test] fn an_unchanged_graph_event_still_emits() { let mut graph = Graph::new(); graph.app_node("music", MediaRole::StreamOutput, 100); let projection = ready(graph.build()); let mut auditor = auditor_off(); assert!(observe(&mut auditor, &projection, 0).emit); assert!(observe(&mut auditor, &projection, 1).emit); } /// `seq` counts every recompute, emitted or not, so a gap in the emitted /// sequence is visibly a suppression rather than a lost line. Without this, a /// reader cannot tell a quiet audit from a broken one. #[test] fn seq_counts_suppressed_recomputes_too() { let mut graph = Graph::new(); graph.app_node("music", MediaRole::StreamOutput, 100); let projection = ready(graph.build()); let mut auditor = auditor_off(); assert_eq!(observe(&mut auditor, &projection, 0).record.seq, 1); let suppressed = auditor.observe(&projection, EventKind::Tick, 1); assert!(!suppressed.emit); assert_eq!(suppressed.record.seq, 2); assert_eq!(observe(&mut auditor, &projection, 2).record.seq, 3); } /// Suppression compares against the last record actually *written*, not the last /// one computed. Comparing against the last computed record would let a change /// that appears and reverts between two ticks vanish from the log entirely, /// leaving a reader with a record that no longer matches the state. #[test] fn suppression_compares_against_the_last_emitted_record() { let mut graph = Graph::new(); graph.app_node("music", MediaRole::StreamOutput, 100); let with_music = ready(graph.build()); let empty = ready(Graph::new().build()); let mut auditor = auditor_off(); assert!(observe(&mut auditor, &with_music, 0).emit); // A tick sees a different graph and emits. assert!(auditor.observe(&empty, EventKind::Tick, 1).emit); // The next tick sees the original graph again — different from what was last // written, so it must be emitted. assert!(auditor.observe(&with_music, EventKind::Tick, 2).emit); // And now it matches the last written record. assert!(!auditor.observe(&with_music, EventKind::Tick, 3).emit); } /// The trigger and clock are reported verbatim, which is what lets the O5 event /// rate be recomputed from the record stream alone rather than trusted from the /// summary. #[test] fn the_record_reports_its_trigger_and_clock() { let projection = ready(Graph::new().build()); let mut auditor = auditor_off(); let outcome = auditor.observe(&projection, EventKind::Graph, 42); assert_eq!(outcome.record.trigger, "graph"); assert_eq!(outcome.record.at_ms, 42); let outcome = auditor.observe(&projection, EventKind::Tick, 43); assert_eq!(outcome.record.trigger, "tick"); assert_eq!(outcome.record.at_ms, 43); } /// The taint view spans every media role, not just candidates. A candidate's /// exclusion is usually explained by taint on a node that is not itself a /// candidate — the sink in the middle of a forwarder — and without that the /// record shows the verdict but not the evidence. #[test] fn the_taint_view_covers_non_candidate_roles() { let mut graph = Graph::new(); let call = graph.peerspeak_node("peerspeak-call", 200); let sink = graph.module_node("null-sink", MediaRole::Sink, 70); graph.link(call, sink); let body = observe(&mut auditor_off(), &ready(graph.build()), 0) .record .body; let tainted: Vec<(&str, &str)> = body .taint .iter() .map(|row| (row.name.as_deref().unwrap_or("?"), row.reason)) .collect(); assert!( tainted.contains(&("null-sink", "tainted-upstream")), "the sink is not a candidate but its taint is what explains the row: {tainted:?}" ); assert!(tainted.contains(&("peerspeak-call", "peerspeak-owned"))); } /// A record must serialise to a single line. Newlines inside a JSON Lines /// record would split one record into two unparseable ones — and node names come /// from PipeWire properties, which are attacker-adjacent free text. #[test] fn a_record_serialises_to_exactly_one_line() { let mut graph = Graph::new(); graph.app_node("evil\nname\r\nwith breaks", MediaRole::StreamOutput, 100); let projection = ready(graph.build()); let outcome = observe(&mut auditor_off(), &projection, 0); let json = serde_json::to_string(&outcome.record).expect("a record must serialise"); assert_eq!(json.lines().count(), 1, "record split across lines: {json}"); assert!( json.contains(r"evil\nname"), "the name must survive escaped" ); } // ── O5 metrics ──────────────────────────────────────────────────────────── fn sample(kind: EventKind, at_us: u64, gap_us: u64, recompute_us: u64, emit_us: u64) -> Sample { Sample { at_us, gap_us, recompute_us, emit_us, kind, } } /// Bucket bounds are exclusive upper bounds, so a value exactly on a bound lands /// in the next bucket up. Asserted because an off-by-one here silently shifts /// the whole distribution the O5 conclusion rests on. #[test] fn histogram_bounds_are_exclusive_upper_bounds() { let mut metrics = Metrics::default(); for us in [0, 49, 50, 99_999, 100_000, 1_000_000] { metrics.record(sample(EventKind::Graph, 0, 1_000, us, 0)); } let summary = metrics.summary(); assert_eq!( summary.recompute_distribution, vec![ ("<50us", 2), // 0 and 49 ("<100us", 1), // 50 ("<100ms", 1), // 99_999 (">=100ms", 2), // 100_000 and 1_000_000 ] ); assert_eq!(summary.recompute_max_us, 1_000_000); } /// The maximum is exact, not bucketed. O5 asks for the maximum specifically, and /// ">= 100 ms" is not an answer to "how bad does it get?". #[test] fn the_maximum_is_exact_not_bucketed() { let mut metrics = Metrics::default(); metrics.record(sample(EventKind::Graph, 0, 1_000, 137, 0)); metrics.record(sample(EventKind::Graph, 0, 1_000, 4_211, 0)); metrics.record(sample(EventKind::Graph, 0, 1_000, 90, 0)); let summary = metrics.summary(); assert_eq!(summary.recompute_max_us, 4_211); assert_eq!(summary.recompute_mean_us, Some((137 + 4_211 + 90) / 3)); } /// Nearest-rank quantiles over the buckets. #[test] fn quantiles_use_nearest_rank_over_the_buckets() { let mut metrics = Metrics::default(); // 99 fast samples and one very slow one: the tail must show up at p99 and // nowhere earlier, which is the whole reason for reporting p99 at all. for _ in 0..99 { metrics.record(sample(EventKind::Graph, 0, 1_000, 10, 0)); } metrics.record(sample(EventKind::Graph, 0, 1_000, 200_000, 0)); let summary = metrics.summary(); assert_eq!(summary.recompute_p50, Some("<50us")); assert_eq!(summary.recompute_p90, Some("<50us")); assert_eq!(summary.recompute_p99, Some("<50us")); assert_eq!(summary.recompute_max_us, 200_000); } #[test] fn an_empty_histogram_reports_no_quantiles_and_no_rate() { let summary = Metrics::default().summary(); assert_eq!(summary.recompute_p50, None); assert_eq!(summary.recompute_mean_us, None); assert_eq!(summary.graph_events_per_sec, None); assert_eq!(summary.busy_fraction, None); assert_eq!(summary.recompute_max_us, 0); assert!(summary.recompute_distribution.is_empty()); } /// Ticks are counted separately from graph events. Folding them in would inflate /// the measured event rate by a constant 4 Hz and hide the real graph churn — /// which is the number O5 is actually about. #[test] fn ticks_do_not_count_toward_the_graph_event_rate() { let mut metrics = Metrics::default(); // Two graph events one second apart, with ticks in between. metrics.record(sample(EventKind::Graph, 0, 0, 100, 0)); for i in 1..4 { metrics.record(sample(EventKind::Tick, i * 250_000, 250_000, 100, 0)); } metrics.record(sample(EventKind::Graph, 1_000_000, 250_000, 100, 0)); let summary = metrics.summary(); assert_eq!(summary.graph_events, 2); assert_eq!(summary.tick_events, 3); // Span runs to the last sample's completion: 1_000_000 + 100 µs. assert_eq!(summary.span_us, 1_000_100); assert_eq!(summary.graph_events_per_sec, Some(2.0)); } /// The queueing proxy: an event beginning within the threshold of the previous /// sample's completion was almost certainly already waiting. The first sample is /// never counted — it has no predecessor to have queued behind, and counting it /// would put a phantom backlog in every run. #[test] fn the_queueing_proxy_counts_back_to_back_events_only() { let mut metrics = Metrics::default(); metrics.record(sample(EventKind::Graph, 0, 0, 100, 0)); metrics.record(sample(EventKind::Graph, 100, QUEUE_THRESHOLD_US, 100, 0)); metrics.record(sample( EventKind::Graph, 200, QUEUE_THRESHOLD_US + 1, 100, 0, )); metrics.record(sample(EventKind::Graph, 300, 0, 100, 0)); let summary = metrics.summary(); assert_eq!( summary.queued_events, 2, "exactly the two within the threshold, never the first sample" ); assert_eq!(summary.queue_threshold_us, QUEUE_THRESHOLD_US); } /// Emission cost is tracked separately from recompute cost, and a suppressed /// record contributes neither an emitted-record count nor an emit sample — /// otherwise the logging distribution would be diluted by every tick that wrote /// nothing. #[test] fn emission_cost_is_tracked_separately_from_recompute() { let mut metrics = Metrics::default(); metrics.record(sample(EventKind::Graph, 0, 0, 300, 80)); metrics.record(sample(EventKind::Tick, 1_000, 900, 200, 0)); metrics.record(sample(EventKind::Graph, 2_000, 900, 400, 120)); let summary = metrics.summary(); assert_eq!(summary.emitted_records, 2); assert_eq!(summary.emit_max_us, 120); assert_eq!(summary.emit_mean_us, Some(100)); assert_eq!( summary.emit_distribution, vec![("<100us", 1), ("<250us", 1)] ); // Busy time is recompute *and* logging: 300+80+200+400+120. assert_eq!(summary.busy_us, 1_100); } /// The busy fraction needs no inference, unlike the queueing proxy, so it is the /// number the O5 verdict should lean on. #[test] fn the_busy_fraction_is_the_share_of_wall_time_spent_working() { let mut metrics = Metrics::default(); metrics.record(sample(EventKind::Graph, 0, 0, 100, 0)); // Ends at 1_000_000 + 900 → a span of 1_000_900 µs with 1_000 µs of work. metrics.record(sample(EventKind::Graph, 1_000_000, 999_900, 900, 0)); let summary = metrics.summary(); assert_eq!(summary.busy_us, 1_000); assert_eq!(summary.span_us, 1_000_900); assert_eq!(summary.busy_fraction, Some(0.001)); } /// Bucket labels and bounds must stay parallel, or the distribution mislabels /// itself — a silent failure that would misreport every O5 result. #[test] fn bucket_labels_cover_every_bound_plus_overflow() { assert_eq!( BUCKET_LABELS.len(), super::metrics::BUCKET_BOUNDS_US.len() + 1 ); }