memlay 0.1.4

Repo-native, conflict-resistant shared memory and codebase navigation layer for AI coding agents
//! Property-based tests (PRD §25.2).
#![cfg(test)]

use crate::memgraph;
use crate::records::store::LoadedRecord;
use crate::records::{mrf, Confidence, Kind, Op, Record};
use chrono::TimeZone;
use proptest::prelude::*;
use uuid::Uuid;

/// A value safe for one MRF line: non-empty, no control chars, no edge spaces.
fn value_strategy() -> impl Strategy<Value = String> {
    proptest::string::string_regex("[a-zA-Z0-9 ,.;:!?()\\[\\]{}'\"/@#$%^&*+=_~-]{1,120}")
        .unwrap()
        .prop_map(|s| {
            let t = s.trim().to_string();
            if t.is_empty() {
                "x".to_string()
            } else {
                t
            }
        })
}

fn key_strategy() -> impl Strategy<Value = String> {
    proptest::string::string_regex("[a-z0-9]{1,10}(\\.[a-z0-9-]{1,10}){0,3}").unwrap()
}

fn record_strategy() -> impl Strategy<Value = Record> {
    (
        key_strategy(),
        value_strategy(),
        proptest::option::of(value_strategy()),
        proptest::collection::vec(value_strategy(), 0..4),
        proptest::collection::vec("[a-z][a-z0-9-]{0,15}", 0..3),
    )
        .prop_map(|(key, summary, rationale, details, tags)| Record {
            id: Uuid::now_v7(),
            key,
            kind: Kind::DomainFact,
            op: Op::Assert,
            summary,
            rationale,
            confidence: Confidence::Verified,
            created_at: chrono::Utc
                .with_ymd_and_hms(2026, 7, 27, 10, 30, 0)
                .unwrap(),
            writer: "w1".into(),
            human: None,
            agent: None,
            session: None,
            pr: None,
            issue: None,
            alias_key: None,
            canonical_key: None,
            details,
            alternatives: vec![],
            consequences: vec![],
            paths: vec![],
            symbols: vec![],
            tags,
            evidence: vec![],
            supersedes: vec![],
            related: vec![],
            extensions: vec![],
        })
}

proptest! {
    /// Arbitrary valid records round-trip through canonical MRF losslessly,
    /// and canonical serialization is a fixed point.
    #[test]
    fn mrf_round_trip(record in record_strategy()) {
        let text = mrf::serialize(&record);
        let parsed = mrf::parse(text.as_bytes()).unwrap();
        prop_assert_eq!(&parsed, &record);
        prop_assert_eq!(mrf::serialize(&parsed), text);
    }

    /// Logical records survive JSON export/import without loss.
    #[test]
    fn json_round_trip(record in record_strategy()) {
        let json = serde_json::to_string(&record).unwrap();
        let back: Record = serde_json::from_str(&json).unwrap();
        prop_assert_eq!(back, record);
    }

    /// Every nonempty acyclic supersession chain has exactly one head, and it
    /// is the last record in the chain.
    #[test]
    fn chain_head_derivation(len in 1usize..12) {
        let mut records: Vec<LoadedRecord> = Vec::new();
        let mut prev: Option<Uuid> = None;
        for _ in 0..len {
            let mut r = Record {
                id: Uuid::now_v7(),
                key: "a.chain".into(),
                kind: Kind::DomainFact,
                op: Op::Assert,
                summary: "S.".into(),
                rationale: None,
                confidence: Confidence::Verified,
                created_at: chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
                writer: "w".into(),
                human: None, agent: None, session: None, pr: None, issue: None,
                alias_key: None, canonical_key: None,
                details: vec![], alternatives: vec![], consequences: vec![],
                paths: vec![], symbols: vec![], tags: vec!["t".into()],
                evidence: vec![], supersedes: vec![], related: vec![], extensions: vec![],
            };
            if let Some(p) = prev {
                r.supersedes = vec![p];
            }
            prev = Some(r.id);
            records.push(LoadedRecord { rel_path: r.relative_path(), record: r, issues: vec![] });
        }
        let graph = memgraph::build(&records);
        let state = &graph.keys["a.chain"];
        prop_assert_eq!(state.head_ids.len(), 1);
        prop_assert_eq!(state.head_ids[0], prev.unwrap());
        prop_assert!(!state.conflicted);
    }

    /// Random DAGs (each record supersedes a subset of earlier records) always
    /// yield at least one head and never a conflict-free multi-head lie:
    /// heads are exactly the non-superseded records.
    #[test]
    fn dag_heads_are_exactly_unsuperseded(edges in proptest::collection::vec(proptest::bool::ANY, 0..40)) {
        let n = 8usize;
        let mut records: Vec<LoadedRecord> = Vec::new();
        let mut ids: Vec<Uuid> = Vec::new();
        let mut edge_iter = edges.into_iter();
        for i in 0..n {
            let mut supersedes = Vec::new();
            for prior in ids.iter().take(i) {
                if edge_iter.next().unwrap_or(false) {
                    supersedes.push(*prior);
                }
            }
            let r = Record {
                id: Uuid::now_v7(),
                key: "a.dag".into(),
                kind: Kind::DomainFact,
                op: Op::Assert,
                summary: "S.".into(),
                rationale: None,
                confidence: Confidence::Verified,
                created_at: chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
                writer: "w".into(),
                human: None, agent: None, session: None, pr: None, issue: None,
                alias_key: None, canonical_key: None,
                details: vec![], alternatives: vec![], consequences: vec![],
                paths: vec![], symbols: vec![], tags: vec!["t".into()],
                evidence: vec![], supersedes, related: vec![], extensions: vec![],
            };
            ids.push(r.id);
            records.push(LoadedRecord { rel_path: r.relative_path(), record: r, issues: vec![] });
        }
        let graph = memgraph::build(&records);
        let state = &graph.keys["a.dag"];
        // Later records supersede earlier ones only: acyclic, so >= 1 head.
        prop_assert!(!state.head_ids.is_empty());
        let superseded: std::collections::HashSet<Uuid> = records
            .iter()
            .flat_map(|r| r.record.supersedes.iter().copied())
            .collect();
        let expected: Vec<Uuid> = {
            let mut v: Vec<Uuid> = ids.iter().copied().filter(|i| !superseded.contains(i)).collect();
            v.sort();
            v
        };
        prop_assert_eq!(state.head_ids.clone(), expected);
        prop_assert_eq!(state.conflicted, state.head_ids.len() > 1);
    }

    /// The token packer never materially exceeds its budget.
    #[test]
    fn packer_respects_budget(budget in 200u32..4000, n in 0usize..60) {
        use crate::retrieval::{ContextResult, MemoryItem};
        use crate::team::{Revisions, SyncState};
        let result = ContextResult {
            revisions: Revisions {
                baseline_ref: "refs/remotes/origin/main".into(),
                team_memory_revision: "abcdef0123456789".into(),
                local_head: None,
                branch_memory_revision: "b".into(),
                working_memory_revision: "empty".into(),
                last_fetch_at: None,
                sync_state: SyncState::Current,
                shared_memory_exact: true,
                memlay_version: "0".into(),
                record_format_version: 1,
            },
            branch: Some("main".into()),
            dirty: false,
            modules: vec![],
            memories: (0..n).map(|i| MemoryItem {
                ref_id: format!("memory:{i}"),
                key: format!("k.{i}"),
                kind: "decision".into(),
                summary: "A ".repeat(60),
                rationale: Some("Because ".repeat(30)),
                confidence: "verified".into(),
                layer: "team".into(),
                score: 100,
                reason: "r".into(),
                stale: false,
                conflicted: false,
                introduced_by: None,
                agent: None,
                created_at: "2026-01-01T00:00:00Z".into(),
            }).collect(),
            code_targets: vec![],
            files: vec![],
            tests: vec![],
            edges: vec![],
            changes: vec![],
            conflicts: vec![],
            warnings: vec![],
            estimated_tokens: 0,
            token_budget: budget,
        };
        let text = crate::retrieval::mcf::render(&result, budget, false);
        let tokens = crate::retrieval::query::estimate_tokens(&text);
        prop_assert!(tokens <= budget + budget / 10 + 10, "budget {} -> {}", budget, tokens);
    }

    /// Redaction never returns a configured secret string.
    #[test]
    fn redaction_removes_configured_secrets(
        prefix in value_strategy(),
        secret in "[A-Za-z0-9]{12,40}",
        suffix in value_strategy(),
    ) {
        let text = format!("{prefix} {secret} {suffix}");
        let (out, _) = crate::audit::redact(&text, std::slice::from_ref(&secret));
        prop_assert!(!out.contains(&secret));
    }
}

/// MCF must use at least 25% fewer estimated tokens than the equivalent
/// pretty JSON on a representative fixture (PRD §22.1).
#[test]
fn mcf_token_reduction_vs_pretty_json() {
    use crate::retrieval::{ChangeItem, CodeItem, ContextResult, EdgeItem, MemoryItem};
    use crate::team::{Revisions, SyncState};
    let result = ContextResult {
        revisions: Revisions {
            baseline_ref: "refs/remotes/origin/main".into(),
            team_memory_revision: "4f12ab0987654321".into(),
            local_head: Some("deadbeef".into()),
            branch_memory_revision: "b1".into(),
            working_memory_revision: "empty".into(),
            last_fetch_at: None,
            sync_state: SyncState::Current,
            shared_memory_exact: true,
            memlay_version: "0.1.0".into(),
            record_format_version: 1,
        },
        branch: Some("feature/retries".into()),
        dirty: true,
        modules: vec![("apps/api/payments".into(), 3)],
        memories: (0..4)
            .map(|i| MemoryItem {
                ref_id: format!("memory:0000-{i}"),
                key: format!("payments.webhook.policy-{i}"),
                kind: "decision".into(),
                summary: "Provider event IDs are idempotency keys for webhook processing.".into(),
                rationale: Some(
                    "Duplicate provider delivery must not process an event twice.".into(),
                ),
                confidence: "verified".into(),
                layer: "team".into(),
                score: 100,
                reason: "exact key".into(),
                stale: false,
                conflicted: false,
                introduced_by: Some("alice@example.com".into()),
                agent: Some("codex".into()),
                created_at: "2026-07-21T10:12:00Z".into(),
            })
            .collect(),
        code_targets: (0..4)
            .map(|i| CodeItem {
                ref_id: format!("symbol:{i}"),
                symbol: format!("PaymentWebhookController.handle{i}"),
                kind: "method".into(),
                path: "apps/api/payments/webhook.ts".into(),
                line: 41,
                signature: "handle(req: Request): void".into(),
                score: 95,
                reason: "exact symbol".into(),
                is_test: false,
            })
            .collect(),
        files: vec![],
        tests: vec![],
        edges: vec![EdgeItem {
            from: "apps/api/payments/webhook.ts".into(),
            to: "queue".into(),
        }],
        changes: vec![ChangeItem {
            ref_id: "memory:c1".into(),
            summary: "Moved processing to the asynchronous consumer.".into(),
            date: "2026-07-21".into(),
            human: Some("alice@example.com".into()),
            agent: Some("codex".into()),
            layer: "team".into(),
        }],
        conflicts: vec![],
        warnings: vec![],
        estimated_tokens: 0,
        token_budget: 4000,
    };
    let mcf = crate::retrieval::mcf::render(&result, 4000, false);
    let json = serde_json::to_string_pretty(&result).unwrap();
    let mcf_tokens = crate::retrieval::query::estimate_tokens(&mcf) as f64;
    let json_tokens = crate::retrieval::query::estimate_tokens(&json) as f64;
    assert!(
        mcf_tokens <= json_tokens * 0.75,
        "MCF {mcf_tokens} tokens vs JSON {json_tokens}: reduction below 25%"
    );
    // Required fields survive the compaction.
    assert!(mcf.contains("team=4f12ab098"));
    assert!(mcf.contains("WHY "));
    assert!(mcf.contains("CHANGE 2026-07-21 alice@example.com + codex"));
}