Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ Follows [Keep a Changelog](https://keepachangelog.com/); versioning is [SemVer](

- sl-viewer menu id taxonomy property surface (WBS-6.2 #457): `crates/sl-viewer/tests/properties_viewer_menu.rs` adds 5 proptest properties — every documented menu id (9 of them: `ID_APP_ABOUT`, `ID_APP_SETTINGS`, `ID_FILE_RELOAD_DISCOVERY`, `ID_FILE_SETTINGS`, `ID_EDIT_FIND`, `ID_VIEW_RELOAD`, `ID_VIEW_TOGGLE_THEME`, `ID_VIEW_COMMAND_PALETTE`, `ID_HELP_TOGGLE`) is non-empty, kebab-case ASCII, carries the `sl-viewer.` prefix, and is unique across the set so a muda event resolves to one DOM action. The menu taxonomy has exactly 9 documented ids so the operator documentation can be re-aligned if it drifts.

- sl-viewer async_states SkeletonLayout property surface (WBS-6.2 #458): `crates/sl-viewer/tests/properties_viewer_async_states.rs` adds 7 proptest properties — `SkeletonLayout::default()` is `Bundles`, exposes exactly three variants (`Bundles`, `ListDetail`, `StreamFeed`), and every variant's `Debug` label is non-empty, single-line, and matches one of the documented names. `list_rows.clamp(3, 6)` lands in `[3, 6]` for every input, is monotonic non-decreasing, and has the documented fixed points (`0` / `2` → `3`, `6` / `usize::MAX` → `6`).

- session-ledger OKF document validator property surface (WBS-6.2 #459): `crates/sl-viewer/tests/properties_session_ledger_okf.rs` adds 12 proptest properties pinning `session_ledger::OkfDocument::new` and `validate_okf_document` (the OKF v1 graph validator that backs the export / wiki / search pipeline). `new(b, c)` always produces `okf = "1.0"`, propagates `bundle.source_id` into `source_id` and `provenance.source_id`, propagates `c` into `provenance.corpus`, and starts with empty entities/relations/tags. `validate_okf_document` reports exactly one `unsupported_version` error per non-`"1.0"` `okf` (with the offending version in the message), exactly one `source_id_mismatch` error per provenance/source mismatch, exactly one `duplicate_entity_id` error per duplicate entity occurrence, `dangling_relation_source`/`dangling_relation_target` errors with field paths, and every `OkfValidationError` carries non-empty `field`/`code`/`message`.

- session-ledger worklog projector property surface (WBS-6.2 #460): `crates/sl-viewer/tests/properties_session_ledger_worklog.rs` adds 11 proptest properties pinning the crash-recovery / lost-work projector. Empty sessions project `None`; final user turns project as `AwaitingAssistantResponse`; final tool/subagent turns project as `InterruptedExecution`; final assistant turns with one of the 9 documented completion markers (`complete`, `completed`, `done`, `[completed]`, `<completed>`, `status: complete`, `status: completed`, `task complete`, `task completed`) project as `None`; final assistant turns without any marker project as `MissingCompletionMarker`; the summary never exceeds 241 characters, is single-line, and carries the originating session id, corpus, and message count. `project_unfinished_work` returns one item per unfinished session in input order and is deterministic. `WorklogProjection::from_session` carries `message_count` and matches `detect_unfinished` exactly.

- session-ledger OKF export adapter property surface (WBS-6.2 #461): `crates/sl-viewer/tests/properties_session_ledger_export.rs` adds 8 proptest properties pinning `session_ledger::export_to_okf` (the main entry point of the OKF v1 export pipeline). `export_to_okf` always produces `okf = "1.0"` and propagates `bundle.source_id` into `source_id` and `provenance.source_id`, propagates the corpus arg into `provenance.corpus`, and produces zero entities/relations/tags on an empty bundle. Every exported document passes `validate_okf_document` (validator contract for downstream consumers). `export_to_okf` is deterministic across calls. Intent bundles always emit exactly one goal entity (label = goal) plus one `acceptance` entity per acceptance signal and one `constraint` entity per constraint. Context bundles emit exactly one `resource` entity when `cwd` is present. Acceptance bundles emit exactly one `gate` entity with `label = "resume-gate"` and `properties.ready = true` / `properties.scope_sized = true`. The exporter never produces duplicate entity ids across multiple intents/contexts/acceptances/contracts.

- session-ledger distill compiler property surface (WBS-6.2 #462): `crates/sl-viewer/tests/properties_session_ledger_distill.rs` adds 9 proptest properties pinning `session_ledger::distill::compile` and `compile_and_store` (the deterministic compilation pipeline that turns a `Session` into a `ContinuationBundle` + episodic memories). `compile` always produces a bundle whose `source_id` equals `session.id`, is injectable (carries an `Acceptance` slice), emits one slice for every documented kind (`Acceptance` / `Intent` / `Context` / `Contract` / `Provenance` / `Worklog`) — even for empty sessions — and whose `total_token_estimate()` equals the sum of per-slice `token_estimate` values. `compile` is deterministic across calls. The `Worklog` slice's body deserializes to a `WorklogProjection` whose `message_count` equals `session.messages.len()`. `compile_and_store` returns an injectable bundle with the input `source_id`, writes exactly 3 episodic memories (intent / contract / context) to the memory store, and is deterministic across fresh stores.

- session-ledger heuristic intent extractor property surface (WBS-6.2 #463): `crates/sl-viewer/tests/properties_session_ledger_intent.rs` adds 10 proptest properties pinning `session_ledger::distill::extractor::HeuristicIntentExtractor::extract_intent` (the P1 SSOT for what the user wants — drives resume prompt, search index, and wiki/docs view). `user_turn_count` always equals the count of `Role::User` messages and ignores `Assistant` / `Subagent` / `Tool` / `System` messages. Empty sessions produce an empty `Intent`. Repeated acceptance / constraint patterns are deduplicated. Every documented acceptance pattern (16 of them: `looks good`, `works`, `that's correct`, `correct`, `done`, `fixed`, `passes`, `approved`, `looks right`, `looks great`, `all good`, `that works`, `nice`, `perfect`, `exactly`, `confirmed`) and every documented constraint pattern (19 of them: `don't change`, `do not change`, `must not`, `should not`, `keep`, `maintain`, `preserve`, `never`, `don't touch`, `do not touch`, `don't modify`, `do not modify`, `only`, `but don't`, `but do not`, `without changing`, `without modifying`, `leave alone`, `leave as is`) is recognized in any user message. Labeled `Goal:` / `Objective:` / `Task:` lines win over surrounding preamble. Labeled `Constraint:` / `Requirement:` / `Boundary:` lines contribute their full text. The extractor is deterministic across calls.

- Commit signing header scan (C04 L34): `commit-signing-check.ps1` reads bounded commit headers via line-scanner (no unbounded `git cat-file` buffers or `(?ms)` regex); `-SelfCheck` + `tests/commit_signing_check.rs`.

- Loom permutation CI timeout (P0 stability): split blocking `loom-permutation.yml` into core + per-daemon `loom_model` jobs with `LOOM_MAX_PREEMPTIONS` on broadcast/pipeline/shutdown; mirror in soft `loom-smoke.yml` so Wave-40 tokio-shaped daemon graph tests no longer exceed single-job ceilings.
Expand Down
198 changes: 198 additions & 0 deletions crates/sl-viewer/tests/properties_session_ledger_distill.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
//! Property evidence for `session_ledger::distill::compile` and
//! `compile_and_store` (the deterministic compilation pipeline
//! that turns a `Session` into a `ContinuationBundle`).
//!
//! The compile pipeline is the SSOT for "what does an injectable
//! bundle look like?". If it stops emitting the documented slice
//! kinds, or `is_injectable()` ever returns false, downstream
//! resume / inject / search all break.

use proptest::prelude::*;
use session_ledger::distill::{compile, compile_and_store, DistillOutput};
use session_ledger::domain::bundle::BundleKind;
use session_ledger::domain::session::{Corpus, Message, Role, Session};
use session_ledger::ports::adapters::InMemoryMemoryStore;
use session_ledger::ports::MemoryStore;

const ALL_KINDS: &[BundleKind] = &[
BundleKind::Acceptance,
BundleKind::Intent,
BundleKind::Context,
BundleKind::Contract,
BundleKind::Provenance,
BundleKind::Worklog,
];

proptest! {
/// `compile(session)` always returns a bundle whose `source_id`
/// equals `session.id`.
#[test]
fn compile_carries_session_id(
session_id in "[a-zA-Z0-9_-]{1,16}",
) {
let session = Session::new(&session_id, Corpus::Forge);
let bundle = compile(&session);
prop_assert_eq!(bundle.source_id, session.id);
}

/// `compile(session)` always returns an injectable bundle
/// (i.e., it carries an `Acceptance` slice). This is the
/// load-bearing contract for resume.
#[test]
fn compile_is_always_injectable(
session_id in "[a-zA-Z0-9_-]{1,16}",
corpus_idx in 0..4_usize,
messages in proptest::collection::vec(
(0..5_usize, role_choice()),
0..5,
),
) {
let corpus = [Corpus::Forge, Corpus::Codex, Corpus::Cursor, Corpus::ClaudeCode][corpus_idx];
let mut session = Session::new(&session_id, corpus);
for (i, role) in messages {
session.messages.push(Message::new(role, format!("message-{i}")));
}
let bundle = compile(&session);
prop_assert!(bundle.is_injectable(),
"compile({session_id}) did not produce an injectable bundle");
prop_assert!(bundle.has(BundleKind::Acceptance));
}

/// `compile(session)` always emits one slice for every documented
/// kind — even when the session is empty.
#[test]
fn compile_emits_all_documented_slice_kinds(
session_id in "[a-zA-Z0-9_-]{1,16}",
corpus_idx in 0..4_usize,
) {
let corpus = [Corpus::Forge, Corpus::Codex, Corpus::Cursor, Corpus::ClaudeCode][corpus_idx];
let session = Session::new(&session_id, corpus);
let bundle = compile(&session);
for kind in ALL_KINDS {
prop_assert!(bundle.has(*kind),
"compile({session_id}) missing {kind:?} slice");
}
}

/// `compile(session)` always returns a bundle whose
/// `total_token_estimate()` equals the sum of per-slice
/// `token_estimate` values.
#[test]
fn compile_token_total_equals_sum(
n_messages in 0_usize..5,
) {
let mut session = Session::new("token-sum", Corpus::Forge);
for i in 0..n_messages {
session.messages.push(Message::new(Role::User, format!("user message {i}")));
}
let bundle = compile(&session);
let sum: u32 = bundle.bundles.iter().map(|slice| slice.token_estimate).sum();
prop_assert_eq!(bundle.total_token_estimate(), sum);
}

/// `compile(session)` is deterministic across calls.
#[test]
fn compile_is_deterministic(
n_messages in 0_usize..4,
corpus_idx in 0..4_usize,
) {
let corpus = [Corpus::Forge, Corpus::Codex, Corpus::Cursor, Corpus::ClaudeCode][corpus_idx];
let mut session = Session::new("det", corpus);
for i in 0..n_messages {
session.messages.push(Message::new(Role::User, format!("msg-{i}")));
}
let a = compile(&session);
let b = compile(&session);
prop_assert_eq!(a, b);
}

/// The `Worklog` slice's body deserializes to a `WorklogProjection`
/// whose `message_count` equals `session.messages.len()`.
#[test]
fn compile_worklog_carries_message_count(
n_messages in 0_usize..5,
) {
let mut session = Session::new("wl", Corpus::Forge);
for i in 0..n_messages {
session.messages.push(Message::new(Role::User, format!("m-{i}")));
}
let bundle = compile(&session);
let worklog = bundle
.bundles
.iter()
.find(|slice| slice.kind == BundleKind::Worklog)
.expect("compile must emit Worklog slice");
let projection: session_ledger::domain::worklog::WorklogProjection =
serde_json::from_value(worklog.body.clone()).expect("worklog body should deserialize");
prop_assert_eq!(projection.message_count, n_messages);
}

/// `compile_and_store` returns an injectable bundle with the same
/// `source_id` as the input session.
#[test]
fn compile_and_store_returns_injectable_bundle(
session_id in "[a-zA-Z0-9_-]{1,16}",
n_messages in 0_usize..3,
) {
let mut session = Session::new(&session_id, Corpus::Forge);
for i in 0..n_messages {
session.messages.push(Message::new(Role::User, format!("do it {i}")));
}
let store = InMemoryMemoryStore::default();
let output: DistillOutput = compile_and_store(&session, &store)
.expect("compile_and_store must succeed for well-formed session");
let source_id = output.bundle.source_id.clone();
prop_assert_eq!(source_id, session.id);
prop_assert!(output.bundle.is_injectable());
}

/// `compile_and_store` writes exactly 3 episodic memories
/// (intent, contract, context) to the memory store.
#[test]
fn compile_and_store_writes_three_memories(
n_messages in 0_usize..3,
) {
let mut session = Session::new("store-3", Corpus::Forge);
for i in 0..n_messages {
session.messages.push(Message::new(Role::User, format!("Goal: m-{i}\nConstraint: c-{i}")));
}
let store = InMemoryMemoryStore::default();
let output = compile_and_store(&session, &store).expect("compile_and_store");
prop_assert_eq!(output.memories.len(), 3,
"expected 3 memories (intent / contract / context), got {}", output.memories.len());
let recalled = store
.recall("session/store-3/episodic", 10)
.expect("recall stored facts");
prop_assert_eq!(recalled.len(), 3);
}

/// `compile_and_store` is deterministic — same session + store
/// produce the same DistillOutput.
#[test]
fn compile_and_store_is_deterministic(
n_messages in 0_usize..3,
) {
let mut session = Session::new("det-store", Corpus::Forge);
for i in 0..n_messages {
session.messages.push(Message::new(Role::User, format!("m {i}")));
}
let store_a = InMemoryMemoryStore::default();
let store_b = InMemoryMemoryStore::default();
let out_a = compile_and_store(&session, &store_a).expect("compile_and_store a");
let out_b = compile_and_store(&session, &store_b).expect("compile_and_store b");
prop_assert_eq!(out_a.bundle, out_b.bundle);
prop_assert_eq!(out_a.memories.len(), out_b.memories.len());
Comment on lines +183 to +184

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The determinism property compares bundles and only the number of memories, not the memory records themselves. Changes to memory IDs, keys, kinds, ordering, or stored content could therefore make compile_and_store nondeterministic while this test still passes; compare the complete memories values and store contents. [logic error]

Severity Level: Major ⚠️
- ⚠️ Memory identifiers and keys can regress undetected.
- ⚠️ Episodic-memory ordering/content is not covered.
- ❌ Determinism evidence can approve inconsistent stored facts.

Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** crates/sl-viewer/tests/properties_session_ledger_distill.rs
**Line:** 183:184
**Comment:**
	*Logic Error: The determinism property compares bundles and only the number of memories, not the memory records themselves. Changes to memory IDs, keys, kinds, ordering, or stored content could therefore make `compile_and_store` nondeterministic while this test still passes; compare the complete `memories` values and store contents.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +181 to +184

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert deterministic memory values.

Line 184 only compares memory counts. Different episodic memories with the same count pass this property. Compare the ordered memory records, or their stable identifier and content fields, across out_a and out_b.

  • crates/sl-viewer/tests/properties_session_ledger_distill.rs#L181-L184: compare the generated memory values, not only memories.len().
  • CHANGELOG.md#L68-L68: retain the deterministic fresh-store claim only after the property compares memory values.
📍 Affects 2 files
  • crates/sl-viewer/tests/properties_session_ledger_distill.rs#L181-L184 (this comment)
  • CHANGELOG.md#L68-L68
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/sl-viewer/tests/properties_session_ledger_distill.rs` around lines 181
- 184, The property around compile_and_store must compare generated memory
values, not only memories.len(); compare the ordered records or their stable
identifiers and content fields between out_a.memories and out_b.memories. In
CHANGELOG.md lines 68-68, retain the deterministic fresh-store claim after the
property validates memory equivalence.

}
}

// ── Strategy helpers ───────────────────────────────────────────────────────

fn role_choice() -> impl Strategy<Value = Role> {
prop::sample::select(vec![
Role::User,
Role::Assistant,
Role::Subagent,
Role::Tool,
Role::System,
])
}
Loading
Loading