diff --git a/CHANGELOG.md b/CHANGELOG.md index d975c8b5..dca0b8b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/); versioning is [SemVer]( - CI drift cleanups (WBS-6.2 #432 follow-up): `scripts/rootless-matrix-check.ps1` `^ rootless-matrix-policy:.*?continue-on-error:\s*true` regex was bleeding across jobs into the next `security:` job's `continue-on-error: true`; replaced with `[regex]::Match` + a proper terminator (`(?=^ [A-Za-z][\w-]*:\s|\z)`) so the check scopes to just the policy block. Same script now throws when the policy block is absent (was treating a failed match as success). `.github/workflows/ci.yml` pins `actions/checkout` to the immutable `3d3c42e5` SHA + `persist-credentials: false` for both policy jobs. `.github/workflows/hermetic.yml` aligns its reusable-workflow pin to the documented `ec891654` SHA (was `a8db485` — drift between pin doc + caller). `crates/sl-viewer/src/web_exports.rs` uses `WebExportProvider::default_subdir` to populate the `defaults` array in `web_export_roots_with_env` instead of repeating literal strings, fixing a `dead_code` warning that broke `cargo test cli_help` under `-D warnings`. +- sl-viewer timeline property surface (WBS-6.2 #433): `crates/sl-viewer/tests/properties_viewer_timeline.rs` adds 16 proptest properties — `group_by_day` partitions every entry into exactly one group with no losses, orders groups chronologically, and labels empty-day groups `"(unknown date)"`. `normalize_widths` produces one width per input entry, all in `[MIN_PX, MAX_PX]`, with all-zero inputs collapsing to MIN_PX and the max-tokened entry rendering at MAX_PX. `model_hue` is deterministic and in `[0, 359]`; `model_color` matches `hsl(, 60%, 55%)`. `TimelineEntry::from_bundle` properties pin: `day` is the leading 10 chars of `created_at` (else empty), `goal` falls back to `"(no goal)"`, `model` falls back to `"unknown"`, `source_id` carries through, `message_count` / `has_acceptance` / `has_contract` match the input, and `token_count` falls back to 0 when no Intent slice carries a numeric `user_turn_count`. + - 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_timeline.rs b/crates/sl-viewer/tests/properties_viewer_timeline.rs new file mode 100644 index 00000000..25c755c2 --- /dev/null +++ b/crates/sl-viewer/tests/properties_viewer_timeline.rs @@ -0,0 +1,428 @@ +//! Property evidence for sl-viewer's `timeline` module. +//! +//! Complements `crates/sl-viewer/src/timeline.rs`'s per-function +//! `#[cfg(test)] mod tests` block by pinning invariants over the *full* +//! shape of the inputs the pure helpers can receive. +//! +//! `timeline` invariants: +//! * `group_by_day` partitions every entry into exactly one group +//! (no losses, no duplicates) and orders groups chronologically. +//! Empty `day` lands in `"(unknown date)"`. +//! * `normalize_widths` produces one width per input entry, all in +//! `[MIN_PX, MAX_PX]`. Empty / all-zero inputs collapse to all +//! `MIN_PX`; the max-tokened entry always renders at `MAX_PX`. +//! * `model_hue` is deterministic and lands in `[0, 359]`. +//! * `model_color` is deterministic and matches `hsl(, 60%, 55%)`. +//! * `TimelineEntry::from_bundle` reduces a `ContinuationBundle` +//! correctly: `day` is the leading 10 chars of `created_at` +//! (else empty), `goal` falls back to `"(no goal)"`, `model` to +//! `"unknown"`, `token_count` / `message_count` / `has_*` match the +//! same reduction `OkfBundle::from_bundle` performs. + +use proptest::prelude::*; +use session_ledger::domain::bundle::{Bundle, BundleKind, ContinuationBundle}; +use sl_viewer::timeline::{ + group_by_day, model_color, model_hue, normalize_widths, TimelineEntry, MAX_PX, MIN_PX, +}; + +// ── strategies ───────────────────────────────────────────────────────────── + +fn entry_strategy() -> impl Strategy { + ( + // source_id — non-empty identifier. + "[a-zA-Z0-9_-]{1,16}", + // day — empty or "YYYY-MM-DD"-shaped (or arbitrary to exercise the + // "unknown date" branch via the empty case). + prop::option::of("[0-9]{4}-[0-9]{2}-[0-9]{2}"), + // created_at — full ISO-8601 with optional suffix. + prop::option::of("[0-9TZ:.+-]{1,24}"), + // token_count — bounded. + 0u64..100_000, + // model — bounded. + "[a-zA-Z0-9._-]{0,16}", + // goal — bounded. + "[a-zA-Z0-9 ._-]{0,32}", + // message_count. + 0usize..16, + // has_acceptance / has_contract. + any::(), + any::(), + ) + .prop_map( + |( + source_id, + day, + created_at, + token_count, + model, + goal, + message_count, + has_acceptance, + has_contract, + )| { + TimelineEntry { + source_id, + day: day.unwrap_or_default(), + created_at: created_at.unwrap_or_default(), + token_count, + model, + goal, + message_count, + has_acceptance, + has_contract, + } + }, + ) +} + +fn continuation_bundle_strategy() -> impl Strategy { + ( + // source_id. + "[a-zA-Z0-9_-]{1,16}", + // 0..6 bundles with optional token / goal / created_at / model in body. + prop::collection::vec( + ( + prop::sample::select(vec![ + BundleKind::Intent, + BundleKind::Acceptance, + BundleKind::Contract, + BundleKind::Context, + ]), + prop::option::of(0u64..100_000), + prop::option::of("[a-zA-Z0-9 ._-]{1,16}"), + prop::option::of("[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9TZ:.+-]{1,14}"), + prop::option::of("[a-zA-Z0-9._-]{1,16}"), + ), + 0..6, + ), + ) + .prop_map(|(source_id, raw)| { + let bundles: Vec = raw + .into_iter() + .map(|(kind, tokens, goal, created_at, model)| { + let mut body = serde_json::Map::new(); + if let Some(t) = tokens { + body.insert("user_turn_count".into(), serde_json::json!(t)); + } + if let Some(g) = goal { + body.insert("goal".into(), serde_json::json!(g)); + } + if let Some(c) = created_at { + body.insert("created_at".into(), serde_json::json!(c)); + } + if let Some(m) = model { + body.insert("model".into(), serde_json::json!(m)); + } + Bundle::new(kind, serde_json::Value::Object(body)) + }) + .collect(); + ContinuationBundle { source_id, bundles } + }) +} + +// ── group_by_day properties ──────────────────────────────────────────────── + +proptest! { + /// Property: `group_by_day` is a partition — every input entry lands + /// in exactly one group, no losses, no duplicates. + #[test] + fn group_by_day_partitions_entries( + entries in prop::collection::vec(entry_strategy(), 0..12), + ) { + let groups = group_by_day(&entries); + let flat: Vec = + groups.iter().flat_map(|(_, v)| v.iter()).cloned().collect(); + prop_assert_eq!( + flat.len(), + entries.len(), + "every input entry must appear in exactly one group", + ); + } + + /// Property: `group_by_day` orders groups chronologically (the empty + /// `day` group lands first because `""` sorts before any "YYYY-..." + /// string in lexicographic order). The first group is the empty-day + /// group only when at least one entry has an empty day. + #[test] + fn group_by_day_groups_ordered_by_day( + entries in prop::collection::vec(entry_strategy(), 0..12), + ) { + let groups = group_by_day(&entries); + let days: Vec<&str> = groups.iter().map(|(d, _)| d.as_str()).collect(); + let mut sorted_days = days.clone(); + sorted_days.sort(); + prop_assert_eq!(&days[..], &sorted_days[..], "groups must be sorted by day ascending"); + } + + /// Property: `group_by_day` collapses every group of empty-day entries + /// under the literal label `"(unknown date)"`. + #[test] + fn group_by_day_empty_day_label_is_unknown( + empty_count in 1usize..5, + known_count in 0usize..5, + ) { + let mut entries: Vec = (0..empty_count) + .map(|i| TimelineEntry { + source_id: format!("empty-{i}"), + day: String::new(), + created_at: String::new(), + token_count: 0, + model: "test".into(), + goal: "test".into(), + message_count: 1, + has_acceptance: false, + has_contract: false, + }) + .collect(); + for i in 0..known_count { + entries.push(TimelineEntry { + source_id: format!("known-{i}"), + day: "2026-01-01".into(), + created_at: "2026-01-01T00:00:00Z".into(), + token_count: 0, + model: "test".into(), + goal: "test".into(), + message_count: 1, + has_acceptance: false, + has_contract: false, + }); + } + let groups = group_by_day(&entries); + let unknown = groups.iter().find(|(d, _)| d == "(unknown date)").expect("unknown group exists"); + prop_assert_eq!(unknown.1.len(), empty_count, "all empty-day entries must land in the unknown group"); + } +} + +// ── normalize_widths properties ───────────────────────────────────────────── + +proptest! { + /// Property: `normalize_widths` produces one width per input entry. + #[test] + fn normalize_widths_length_matches_input( + entries in prop::collection::vec(entry_strategy(), 0..10), + ) { + let widths = normalize_widths(&entries); + prop_assert_eq!(widths.len(), entries.len()); + } + + /// Property: every normalised width falls in `[MIN_PX, MAX_PX]`. + #[test] + fn normalize_widths_in_range( + entries in prop::collection::vec(entry_strategy(), 0..10), + ) { + let widths = normalize_widths(&entries); + for w in &widths { + prop_assert!(*w >= MIN_PX, "width {} must be >= MIN_PX ({})", w, MIN_PX); + prop_assert!(*w <= MAX_PX, "width {} must be <= MAX_PX ({})", w, MAX_PX); + } + } + + /// Property: empty input → empty output (every caller would have to + /// draw nothing). An all-zero slice must collapse to all MIN_PX. + #[test] + fn normalize_widths_all_zero_returns_min(len in 1usize..8) { + let entries: Vec = (0..len) + .map(|i| TimelineEntry { + source_id: format!("e-{i}"), + day: "2026-01-01".into(), + created_at: "2026-01-01T00:00:00Z".into(), + token_count: 0, + model: "test".into(), + goal: "test".into(), + message_count: 1, + has_acceptance: false, + has_contract: false, + }) + .collect(); + let widths = normalize_widths(&entries); + for w in &widths { + prop_assert_eq!(*w, MIN_PX, "all-zero slice must collapse to MIN_PX"); + } + } + + /// Property: the entry with the max `token_count` always renders at + /// `MAX_PX` (the bar scale is anchored at the maximum). + #[test] + fn normalize_widths_max_token_renders_max_px( + // First entry: heavy. Rest: light. + heavy_tokens in 1u64..1_000_000, + light_tokens in 0u64..1000, + rest in 0usize..6, + ) { + let mut entries: Vec = Vec::with_capacity(1 + rest); + entries.push(TimelineEntry { + source_id: "heavy".into(), + day: "2026-01-01".into(), + created_at: "2026-01-01T00:00:00Z".into(), + token_count: heavy_tokens, + model: "test".into(), + goal: "test".into(), + message_count: 1, + has_acceptance: false, + has_contract: false, + }); + for i in 0..rest { + entries.push(TimelineEntry { + source_id: format!("light-{i}"), + day: "2026-01-01".into(), + created_at: "2026-01-01T00:00:00Z".into(), + token_count: light_tokens, + model: "test".into(), + goal: "test".into(), + message_count: 1, + has_acceptance: false, + has_contract: false, + }); + } + let widths = normalize_widths(&entries); + prop_assert_eq!(widths[0], MAX_PX, "the heavy entry must render at MAX_PX"); + } +} + +// ── model hue / color properties ─────────────────────────────────────────── + +proptest! { + /// Property: `model_hue` is deterministic — same input, same output. + #[test] + fn model_hue_is_deterministic(model in "[a-zA-Z0-9._-]{0,32}") { + prop_assert_eq!(model_hue(&model), model_hue(&model)); + } + + /// Property: `model_hue` lands in `[0, 359]` for any input. + #[test] + fn model_hue_in_range(model in "[a-zA-Z0-9._-]{0,32}") { + let h = model_hue(&model); + prop_assert!(h <= 359, "hue {} must be <= 359", h); + } + + /// Property: `model_color` returns `hsl(, 60%, 55%)` and is + /// deterministic. + #[test] + fn model_color_format_and_deterministic(model in "[a-zA-Z0-9._-]{0,32}") { + let a = model_color(&model); + let b = model_color(&model); + prop_assert_eq!(&a, &b); + let expected = format!("hsl({}, 60%, 55%)", model_hue(&model)); + prop_assert_eq!(a, expected); + } +} + +// ── TimelineEntry::from_bundle properties ─────────────────────────────────── + +proptest! { + /// Property: `day` is the leading 10 chars of `created_at` when + /// `created_at.len() >= 10`, else empty. + #[test] + fn from_bundle_day_is_leading_10_chars_or_empty( + prefix in "[0-9TZ:.+-]{10,24}", + suffix in "[0-9TZ:.+-]{0,10}", + ) { + let created_at = format!("{prefix}{suffix}"); + let cb = ContinuationBundle { + source_id: "test".into(), + bundles: vec![Bundle::new( + BundleKind::Context, + serde_json::json!({"created_at": created_at.clone()}), + )], + }; + let entry = TimelineEntry::from_bundle(&cb); + let expected = if created_at.len() >= 10 { created_at[..10].to_owned() } else { String::new() }; + prop_assert_eq!(entry.day, expected); + } + + /// Property: `goal` falls back to `"(no goal)"` when no Intent bundle + /// carries a string `goal` body. + #[test] + fn from_bundle_goal_fallback(variant in 0u8..3) { + let bundles: Vec = match variant { + 0 => Vec::new(), + 1 => vec![Bundle::new(BundleKind::Intent, serde_json::json!({"user_turn_count": 7}))], + _ => vec![Bundle::new( + BundleKind::Intent, + serde_json::json!({"goal": 42}), + )], + }; + let cb = ContinuationBundle { + source_id: "test".into(), + bundles, + }; + let entry = TimelineEntry::from_bundle(&cb); + prop_assert_eq!(entry.goal, "(no goal)"); + } + + /// Property: `model` falls back to `"unknown"` when no Context bundle + /// carries a string `model` body. + #[test] + fn from_bundle_model_fallback(variant in 0u8..3) { + let bundles: Vec = match variant { + 0 => Vec::new(), + 1 => vec![Bundle::new(BundleKind::Context, serde_json::json!({"created_at": "2026-01-01"}))], + _ => vec![Bundle::new( + BundleKind::Context, + serde_json::json!({"model": 42}), + )], + }; + let cb = ContinuationBundle { + source_id: "test".into(), + bundles, + }; + let entry = TimelineEntry::from_bundle(&cb); + prop_assert_eq!(entry.model, "unknown"); + } + + /// Property: `source_id` carries through from the continuation. + #[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 entry = TimelineEntry::from_bundle(&cb); + prop_assert_eq!(entry.source_id, source_id); + } + + /// Property: `has_acceptance` / `has_contract` reflect kind presence + /// (any-of) and `message_count` equals the slice count. + #[test] + fn from_bundle_aggregation_matches_kinds( + kinds in prop::collection::vec( + prop::sample::select(vec![BundleKind::Intent, BundleKind::Acceptance, BundleKind::Contract, BundleKind::Context]), + 0..6, + ), + ) { + let bundles: Vec = kinds + .iter() + .map(|k| Bundle::new(*k, serde_json::json!({}))) + .collect(); + let cb = ContinuationBundle { + source_id: "test".into(), + bundles: bundles.clone(), + }; + let entry = TimelineEntry::from_bundle(&cb); + prop_assert_eq!(entry.message_count, bundles.len()); + prop_assert_eq!(entry.has_acceptance, kinds.contains(&BundleKind::Acceptance)); + prop_assert_eq!(entry.has_contract, kinds.contains(&BundleKind::Contract)); + } + + /// Property: `token_count` falls back to 0 when no Intent bundle + /// carries a numeric `user_turn_count`. + #[test] + fn from_bundle_token_count_zero_when_missing(cb in continuation_bundle_strategy()) { + let entry = TimelineEntry::from_bundle(&cb); + let intent_has_numeric = cb.bundles.iter().any(|b| { + b.kind == BundleKind::Intent + && b.body.get("user_turn_count").and_then(|v| v.as_u64()).is_some() + }); + let expected = if intent_has_numeric { + cb.bundles + .iter() + .find(|b| b.kind == BundleKind::Intent) + .and_then(|b| b.body.get("user_turn_count")) + .and_then(|v| v.as_u64()) + .unwrap_or(0) + } else { + 0 + }; + prop_assert_eq!(entry.token_count, expected); + } +} diff --git a/docs/ops/TRACEABILITY.json b/docs/ops/TRACEABILITY.json index 8bddca2e..b4c595d4 100644 --- a/docs/ops/TRACEABILITY.json +++ b/docs/ops/TRACEABILITY.json @@ -310,6 +310,7 @@ "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", "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 0f5117ae..d5a15d3f 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`; `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; 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`; `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; full loom/shuttle unpaid | ## audit-v38 waves