From f7f2bb9a6e5d216b27bc6843e117f8861f147ec1 Mon Sep 17 00:00:00 2001 From: SessionLedger Bot Date: Sat, 8 Aug 2026 16:40:53 -0700 Subject: [PATCH] test(viewer): bundle_diff proptest surface (WBS-6.2 #434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `crates/sl-viewer/tests/properties_viewer_bundle_diff.rs` with 10 proptest properties pinning the `bundle_diff::diff_fields` and `OkfBundle::from_bundle` reductions: * `diff_fields` returns the documented 9-field set in stable order (guards against UI row-count drift when fields are added). * `diff_fields(a, a)` is reflexive: no fields differ on equal inputs. * `diff_fields(a, a.clone())` is idempotent: clone-mirror produces no differences. * `diff_fields(a, b)` is value-flipped symmetric: `diff_fields(b, a)` swaps `value_a`/`value_b` per field but the `differs` set is identical. * `FieldDiff::differs` matches `value_a != value_b` per field (catches drift where the boolean is computed independently of values). * `Option` fields (model, created_at, goal) render the em-dash fallback (`—`) when both sides are `None`, and the resulting diff is not a difference. * `OkfBundle::from_bundle`: * `message_count` equals the input slice count. * `has_acceptance`/`has_contract` reflect presence of those kinds (any-of) in the input continuation. * `token_count` falls back to 0 when no Intent slice carries a numeric `user_turn_count` (3-variant: missing slice / missing field / non-numeric field). * `source_id` carries through from the continuation unchanged. Updates WBS-6.2 evidence list, TRACEABILITY.json, and CHANGELOG. --- CHANGELOG.md | 2 + .../tests/properties_viewer_bundle_diff.rs | 290 ++++++++++++++++++ docs/ops/TRACEABILITY.json | 1 + docs/ops/WBS.md | 2 +- 4 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 crates/sl-viewer/tests/properties_viewer_bundle_diff.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index effc78f9..36c8059d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/); versioning is [SemVer]( - sl-viewer history_tab property surface (WBS-6.2 #444): `crates/sl-viewer/tests/properties_viewer_history.rs` adds 15 proptest properties — `history_tab::to_timeline_entry` carries `summary.id` / `summary.title` / `summary.message_count` / `summary.intent_state` (= `Extracted`), `corpus`, and `cwd` through unchanged; `message_previews` is capped at 3 (empty when input has no messages); `total_messages` matches input. `unfinished` is `false` for empty sessions, `false` when the last message content (case-insensitive) contains one of the six documented done-phrases ("looks good", "approved", "ship it", "all good", "thanks", "done"), and `true` otherwise. `to_timeline_entry` is deterministic. `all_timeline_entries` produces one entry per input session, sorts by `total_messages` descending (newest-first), every session id appears exactly once, and is deterministic. +- sl-viewer bundle-diff property surface (WBS-6.2 #434): `crates/sl-viewer/tests/properties_viewer_bundle_diff.rs` adds 10 proptest properties — `diff_fields` returns the documented field set in stable order, is reflexive on `a == a`, idempotent on `a == a.clone()`, value-flipped symmetric (`diff_fields(b, a)` swaps `value_a`/`value_b` while `differs` matches), `differs` matches `value_a != value_b`, and `Option` fields render the em-dash fallback when both sides are `None`. `OkfBundle::from_bundle` properties pin the reduction: `message_count` matches slice count, `has_acceptance`/`has_contract` reflect kind presence, `token_count` falls back to 0 when no Intent slice carries numeric `user_turn_count`, `source_id` carries through unchanged. + - Wave-44 plan landed: `WAVE44_SCOPE.md` + `docs/ops/WAVE44_PERT.md` enumerate 6 close-out lanes (3 machine, 3 human-gated) for the 6 unpaid residuals from Wave-43 (396/402 → 402/402 target). Theme: stack-stability closure + i18n migration + eval coverage + supply-chain signing. - Wave-44 reaudit (Wave-44-D): `audit/SCORECARD.md` refresh at commit `13c974f7` (machine-w44-reaudit); `docs/ops/TRACEABILITY.json` overall_audit wave=Wave-44 commit=13c974f7 (conservative hold at 396/402); `docs/ops/GAP_QA_MATRIX.md` C00 + C08 + PLAN-W8-B rows reflect Wave-44 closure (#368 W44-B6 corpus / #372 W44-B1 loom / #373 PERT correction). 2 of 3 machine lanes shipped 2026-07-24; remaining 6 raw pts across C04 L36 / C08 L76 / C11 L110. diff --git a/crates/sl-viewer/tests/properties_viewer_bundle_diff.rs b/crates/sl-viewer/tests/properties_viewer_bundle_diff.rs new file mode 100644 index 00000000..c3be8954 --- /dev/null +++ b/crates/sl-viewer/tests/properties_viewer_bundle_diff.rs @@ -0,0 +1,290 @@ +//! Property evidence for sl-viewer's `bundle_diff` module. +//! +//! Complements `crates/sl-viewer/src/bundle_diff.rs`'s per-function +//! `#[cfg(test)] mod tests` block by pinning invariants over the *full* +//! shape of the inputs the pure-function diff logic can receive. +//! +//! `bundle_diff` invariants: +//! * `diff_fields` is total: it always returns exactly one `FieldDiff` +//! per documented field (9 today), in a stable order so the UI never +//! reorders rows. +//! * `diff_fields(a, a)` has no differing fields (reflexive). +//! * `diff_fields(a, b)` is "value-flipped" symmetric: swapping inputs +//! swaps `value_a` / `value_b` per field but preserves the set of +//! fields that differ. +//! * `FieldDiff::differs` matches `value_a != value_b` per field. +//! * `Option`-valued fields render their em-dash fallback when +//! both sides are `None`; the resulting `differs` is `false`. +//! * `OkfBundle::from_bundle` reduces a `ContinuationBundle` correctly: +//! `message_count` is the slice count, `has_acceptance` / `has_contract` +//! reflect presence of those kinds, and `token_count` falls back to 0 +//! when no `Intent` slice carries a numeric `user_turn_count`. + +use proptest::prelude::*; +use session_ledger::domain::bundle::{Bundle, BundleKind, ContinuationBundle}; +use sl_viewer::bundle_diff::{diff_fields, FieldDiff, OkfBundle}; + +// ── strategies ───────────────────────────────────────────────────────────── + +const EXPECTED_FIELD_NAMES: &[&str] = &[ + "source_id", + "token_count", + "message_count", + "duration_ms", + "model", + "created_at", + "goal", + "has_acceptance", + "has_contract", +]; + +fn okf_bundle_strategy() -> impl Strategy { + ( + // source_id — non-empty identifier-shaped string. + "[a-zA-Z0-9_-]{1,16}", + // token_count — bounded u64. + 0u64..1_000_000, + // message_count — bounded usize. + 0usize..16, + // duration_ms — bounded u64. + 0u64..1_000_000, + // model — Some(str) or None (None rendered as em-dash). + prop::option::of("[a-zA-Z0-9 ._-]{1,32}"), + // created_at — ISO-shaped or None. + prop::option::of("[0-9T:Z.+-]{1,24}"), + // goal — Some(str) or None. + prop::option::of("[a-zA-Z0-9 ._-]{1,40}"), + // has_acceptance, has_contract. + any::(), + any::(), + ) + .prop_map( + |( + source_id, + token_count, + message_count, + duration_ms, + model, + created_at, + goal, + has_acceptance, + has_contract, + )| { + OkfBundle { + source_id, + token_count, + message_count, + duration_ms, + model, + created_at, + goal, + has_acceptance, + has_contract, + } + }, + ) +} + +// ── diff_fields properties ───────────────────────────────────────────────── + +proptest! { + /// Property: `diff_fields` is total — always returns exactly one + /// `FieldDiff` per documented field, in stable order. Guards against + /// drift between the row count the UI expects and the diff emits. + #[test] + fn diff_fields_returns_full_stable_field_set( + a in okf_bundle_strategy(), + b in okf_bundle_strategy(), + ) { + let diffs = diff_fields(&a, &b); + prop_assert_eq!(diffs.len(), EXPECTED_FIELD_NAMES.len(), "diff length must match documented field count"); + let names: Vec<&str> = diffs.iter().map(|d| d.name).collect(); + prop_assert_eq!(&names[..], EXPECTED_FIELD_NAMES, "field names must be stable"); + } + + /// Property: `diff_fields(a, a)` is reflexive — no fields differ when + /// both sides are equal. Catches off-by-one comparisons and missed + /// field copy bugs. + #[test] + fn diff_fields_reflexive_no_differs(a in okf_bundle_strategy()) { + let diffs = diff_fields(&a, &a); + for d in &diffs { + prop_assert!( + !d.differs, + "{} should not differ when both sides are the same bundle", + d.name, + ); + prop_assert_eq!(&d.value_a, &d.value_b, "{} values should match on reflexive diff", d.name); + } + } + + /// Property: `diff_fields(a, a.clone())` is also reflexive — a cloned + /// bundle must produce no differences. + #[test] + fn diff_fields_cloned_no_differs(a in okf_bundle_strategy()) { + let diffs = diff_fields(&a, &a.clone()); + for d in &diffs { + prop_assert!(!d.differs, "{} should not differ when both sides are clones", d.name); + } + } + + /// Property: `diff_fields(a, b)` and `diff_fields(b, a)` agree on the + /// set of fields that differ (differs is symmetric), while each + /// field's `value_a` / `value_b` swap accordingly. + #[test] + fn diff_fields_symmetric_differs_swapped_values( + a in okf_bundle_strategy(), + b in okf_bundle_strategy(), + ) { + let ab = diff_fields(&a, &b); + let ba = diff_fields(&b, &a); + prop_assert_eq!(ab.len(), ba.len()); + for (l, r) in ab.iter().zip(ba.iter()) { + prop_assert_eq!(l.name, r.name); + prop_assert_eq!( + l.differs, r.differs, + "differs must be symmetric for field {}", l.name, + ); + prop_assert_eq!(&l.value_a, &r.value_b, "value_a must equal r.value_b for field {}", l.name); + prop_assert_eq!(&l.value_b, &r.value_a, "value_b must equal r.value_a for field {}", l.name); + } + } + + /// Property: `FieldDiff::differs` matches `value_a != value_b`. Catches + /// drift where the boolean is computed independently of the values. + #[test] + fn differs_matches_value_inequality( + a in okf_bundle_strategy(), + b in okf_bundle_strategy(), + ) { + let diffs = diff_fields(&a, &b); + for d in &diffs { + prop_assert_eq!( + d.differs, + d.value_a != d.value_b, + "{}.differs ({}) must match value_a != value_b ({} != {})", + d.name, d.differs, d.value_a, d.value_b, + ); + } + } + + /// Property: `Option` fields render the em-dash fallback when + /// both sides are `None`, and the resulting diff is not a difference. + /// This is the "both absent" contract; the "present vs absent" case + /// is covered by the symmetric-differs / differs-matches-inequality + /// properties above. + #[test] + fn option_fields_use_em_dash_for_both_none( + token_count in 0u64..1000, + message_count in 0usize..16, + ) { + let a = OkfBundle { + source_id: "sess".into(), + token_count, + message_count, + duration_ms: 0, + model: None, + created_at: None, + goal: None, + has_acceptance: false, + has_contract: false, + }; + let diffs = diff_fields(&a, &a); + for name in ["model", "created_at", "goal"] { + let field = diffs.iter().find(|d| d.name == name).expect("field must exist"); + prop_assert_eq!(&field.value_a, "—", "{} must render em-dash for None", name); + prop_assert_eq!(&field.value_b, "—", "{} must render em-dash for None", name); + prop_assert!(!field.differs, "{} must not differ when both sides are None", name); + } + } +} + +// ── OkfBundle::from_bundle properties ────────────────────────────────────── + +proptest! { + /// Property: `message_count` equals the number of bundles in the + /// input continuation. + #[test] + fn from_bundle_message_count_matches_len(slice_count in 0usize..8) { + let bundles: Vec = (0..slice_count) + .map(|i| Bundle::new(BundleKind::Intent, serde_json::json!({"i": i}))) + .collect(); + let cb = ContinuationBundle { + source_id: "test".into(), + bundles, + }; + let okf = OkfBundle::from_bundle(&cb); + prop_assert_eq!(okf.message_count, slice_count); + } + + /// Property: `has_acceptance` is `true` iff any bundle in the input + /// has kind `Acceptance`. Same for `has_contract`. + #[test] + fn from_bundle_has_flags_reflect_kind_presence( + // 0..6 bundles; each may be Intent (i), Acceptance (a), Contract (c). + kinds in prop::collection::vec( + prop::sample::select(vec![BundleKind::Intent, BundleKind::Acceptance, BundleKind::Contract]), + 0..6, + ), + ) { + let bundles: Vec = kinds + .iter() + .map(|k| Bundle::new(*k, serde_json::json!({}))) + .collect(); + let cb = ContinuationBundle { + source_id: "test".into(), + bundles, + }; + let okf = OkfBundle::from_bundle(&cb); + + prop_assert_eq!(okf.has_acceptance, kinds.contains(&BundleKind::Acceptance)); + prop_assert_eq!(okf.has_contract, kinds.contains(&BundleKind::Contract)); + } + + /// Property: `token_count` falls back to 0 when no `Intent` bundle + /// carries a numeric `user_turn_count`. Guards the silent-fallback + /// behaviour documented in the impl. + #[test] + fn from_bundle_token_count_zero_when_no_intent_or_field( + // Variants: 0 = no Intent bundle at all; 1 = Intent without + // user_turn_count; 2 = Intent with non-numeric user_turn_count. + variant in 0u8..3, + ) { + let bundles: Vec = match variant { + 0 => Vec::new(), + 1 => vec![Bundle::new(BundleKind::Intent, serde_json::json!({"goal": "x"}))], + _ => vec![Bundle::new( + BundleKind::Intent, + serde_json::json!({"user_turn_count": "not-a-number"}), + )], + }; + let cb = ContinuationBundle { + source_id: "test".into(), + bundles, + }; + let okf = OkfBundle::from_bundle(&cb); + prop_assert_eq!(okf.token_count, 0, "token_count must default to 0 when missing/non-numeric"); + } + + /// Property: `source_id` carries through from the continuation bundle + /// unchanged. + #[test] + fn from_bundle_source_id_carries_through(source_id in "[a-zA-Z0-9_-]{1,32}") { + let cb = ContinuationBundle { + source_id: source_id.clone(), + bundles: Vec::new(), + }; + let okf = OkfBundle::from_bundle(&cb); + prop_assert_eq!(okf.source_id, source_id); + } +} + +// ── cross-test glue ──────────────────────────────────────────────────────── + +/// Compile-time guarantee that the FieldDiff-derived constants stay in sync. +/// If the impl adds a field, this test fails to compile until EXPECTED_FIELD_NAMES +/// is updated, prompting the reviewer to confirm the UI row count. +#[allow(dead_code)] +const fn _assert_field_count_fits_diff(diff: &[FieldDiff], expected_len: usize) -> bool { + diff.len() == expected_len +} diff --git a/docs/ops/TRACEABILITY.json b/docs/ops/TRACEABILITY.json index a0c0dca0..96442903 100644 --- a/docs/ops/TRACEABILITY.json +++ b/docs/ops/TRACEABILITY.json @@ -313,6 +313,7 @@ "crates/sl-viewer/tests/properties_viewer_timeline.rs", "crates/sl-viewer/tests/properties_viewer_search_memory.rs", "crates/sl-viewer/tests/properties_viewer_history.rs", + "crates/sl-viewer/tests/properties_viewer_bundle_diff.rs", "fuzz/fuzz_targets/okf_roundtrip.rs", "fuzz/fuzz_targets/jsonl_ingest.rs", ".github/workflows/ci.yml", diff --git a/docs/ops/WBS.md b/docs/ops/WBS.md index 50c4e55e..b4d839aa 100644 --- a/docs/ops/WBS.md +++ b/docs/ops/WBS.md @@ -29,7 +29,7 @@ without a new audit. | WBS-4.2 | P4 FTS recall via context-mode and explicit TUI decision | partial | human | `docs/DESIGN.md` §3, §7; `crates/sl-viewer/` | DESIGN P4 residual; C00, C11 | | WBS-5.1 | P5 deterministic dedup merge and crash/lost-work recovery E2E | done | machine | `src/domain/merge.rs`; `src/domain/worklog.rs`; `tests/merge_recovery.rs` | FR-011; T-024, T-035; C03 | | WBS-6.1 | P6 85% coverage gate and deterministic golden corpus | done | machine | `.github/workflows/ci.yml`; `tests/okf_golden.rs`; `tests/fixtures/okf/` | T-037, T-038; C01, C08 | -| WBS-6.2 | P6 property tests, fuzzing, race checks, and enforced performance budgets | partial | machine | `tests/properties.rs`; `crates/sl-viewer/tests/properties_viewer.rs`; `crates/sl-viewer/tests/properties_viewer_theme_url.rs`; `crates/sl-viewer/tests/properties_viewer_unfinished_tab.rs`; `crates/sl-viewer/tests/properties_viewer_timeline.rs`; `crates/sl-viewer/tests/properties_viewer_search_memory.rs`; `crates/sl-viewer/tests/properties_viewer_history.rs`; `fuzz/fuzz_targets/okf_roundtrip.rs`; `fuzz/fuzz_targets/jsonl_ingest.rs`; `.github/workflows/ci.yml`; `.github/workflows/bench-gate.yml`; `docs/ops/perf-baseline.json`; `scripts/bench-gate.ps1`; `benches/pipeline.rs`; `tests/loom_model.rs` | DESIGN P6 residual; C00 L6-L8; C07 L66-L68; C08 L74; perf-budget enforced Wave-26 #223; p95 latency enforced Wave-30 #256; FSM properties Wave-31 #261; soft loom Wave-31 #264; viewer corpus_paths/parquet/settings properties #425; viewer theme + daemon_url properties #427; viewer unfinished_tab properties + fuzz/rootless CI drift fixes #428; viewer bundle_diff + timeline properties + web_exports/hmetic-pin cleanups #432; viewer bundle_diff properties #434; viewer search/memory properties #435; viewer history_tab properties #444; full loom/shuttle unpaid | +| WBS-6.2 | P6 property tests, fuzzing, race checks, and enforced performance budgets | partial | machine | `tests/properties.rs`; `crates/sl-viewer/tests/properties_viewer.rs`; `crates/sl-viewer/tests/properties_viewer_theme_url.rs`; `crates/sl-viewer/tests/properties_viewer_unfinished_tab.rs`; `crates/sl-viewer/tests/properties_viewer_timeline.rs`; `crates/sl-viewer/tests/properties_viewer_search_memory.rs`; `crates/sl-viewer/tests/properties_viewer_history.rs`; `crates/sl-viewer/tests/properties_viewer_bundle_diff.rs`; `fuzz/fuzz_targets/okf_roundtrip.rs`; `fuzz/fuzz_targets/jsonl_ingest.rs`; `.github/workflows/ci.yml`; `.github/workflows/bench-gate.yml`; `docs/ops/perf-baseline.json`; `scripts/bench-gate.ps1`; `benches/pipeline.rs`; `tests/loom_model.rs` | DESIGN P6 residual; C00 L6-L8; C07 L66-L68; C08 L74; perf-budget enforced Wave-26 #223; p95 latency enforced Wave-30 #256; FSM properties Wave-31 #261; soft loom Wave-31 #264; viewer corpus_paths/parquet/settings properties #425; viewer theme + daemon_url properties #427; viewer unfinished_tab properties + fuzz/rootless CI drift fixes #428; viewer bundle_diff + timeline properties + web_exports/hmetic-pin cleanups #432; viewer bundle_diff properties #434; viewer search/memory properties #435; viewer history_tab properties #444; full loom/shuttle unpaid | ## audit-v38 waves