pmat 3.15.0

PMAT - Zero-config AI context generation and code quality toolkit (CLI, MCP, HTTP)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
// Contract Scoring: 5-dimension quality scoring for pmat work contracts
// Spec: docs/specifications/dbc.md §13.4-13.5
//
// Adapted from provable-contracts scoring (spec_depth, falsification, kani, lean, binding)
// to pmat work contract dimensions (spec_depth, falsification, invariant_health,
// subcontracting, traceability).

/// 5-dimension contract quality score
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContractScore {
    /// How comprehensive are the contract clauses? (weight: 0.20)
    pub spec_depth: f64,
    /// What fraction of claims have been verified? (weight: 0.25)
    pub falsification_coverage: f64,
    /// Invariant pass rate across checkpoints (weight: 0.25)
    pub invariant_health: f64,
    /// Monotonic postcondition strengthening score (weight: 0.10)
    pub subcontracting: f64,
    /// Coverage of require/ensure/invariant triad (weight: 0.20)
    pub traceability: f64,
    /// Weighted total score (0.0 to 1.0)
    pub total: f64,
    /// Letter grade (A-F)
    pub grade: ScoreGrade,
}

/// Scoring weights (sum = 1.0)
#[derive(Debug, Clone)]
pub struct ScoringWeights {
    pub spec_depth: f64,
    pub falsification: f64,
    pub invariant_health: f64,
    pub subcontracting: f64,
    pub traceability: f64,
}

impl Default for ScoringWeights {
    fn default() -> Self {
        Self {
            spec_depth: 0.20,
            falsification: 0.25,
            invariant_health: 0.25,
            subcontracting: 0.10,
            traceability: 0.20,
        }
    }
}

/// Letter grades for contract scores
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ScoreGrade {
    A,
    B,
    C,
    D,
    F,
}

impl ScoreGrade {
    #[provable_contracts_macros::contract("pmat-core.yaml", equation = "score_range")]
    /// From score.
    pub fn from_score(score: f64) -> Self {
        if score >= 0.90 {
            ScoreGrade::A
        } else if score >= 0.75 {
            ScoreGrade::B
        } else if score >= 0.60 {
            ScoreGrade::C
        } else if score >= 0.40 {
            ScoreGrade::D
        } else {
            ScoreGrade::F
        }
    }
}

impl std::fmt::Display for ScoreGrade {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ScoreGrade::A => write!(f, "A"),
            ScoreGrade::B => write!(f, "B"),
            ScoreGrade::C => write!(f, "C"),
            ScoreGrade::D => write!(f, "D"),
            ScoreGrade::F => write!(f, "F"),
        }
    }
}

/// Compute the 5-dimension contract score for a work contract.
///
/// Each dimension is scored 0.0..1.0, then weighted and summed.
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "path_exists")]
pub fn score_contract(contract: &WorkContract, project_path: &Path) -> ContractScore {
    debug_assert!(project_path.exists(), "project_path must exist: {}", project_path.display());
    let weights = ScoringWeights::default();

    let spec_depth = compute_spec_depth(contract);
    let falsification_coverage = compute_falsification_coverage(contract);
    let invariant_health = compute_invariant_health(contract, project_path);
    let subcontracting = compute_subcontracting_score(contract);
    let traceability = compute_traceability(contract);

    let total = weights.spec_depth * spec_depth
        + weights.falsification * falsification_coverage
        + weights.invariant_health * invariant_health
        + weights.subcontracting * subcontracting
        + weights.traceability * traceability;

    // All dimensions must be in [0.0, 1.0]
    debug_assert!((0.0..=1.0).contains(&spec_depth), "spec_depth out of range: {}", spec_depth);
    debug_assert!((0.0..=1.0).contains(&falsification_coverage), "falsification out of range: {}", falsification_coverage);
    debug_assert!((0.0..=1.0).contains(&invariant_health), "invariant_health out of range: {}", invariant_health);
    debug_assert!((0.0..=1.0).contains(&subcontracting), "subcontracting out of range: {}", subcontracting);
    debug_assert!((0.0..=1.0).contains(&traceability), "traceability out of range: {}", traceability);
    debug_assert!((0.0..=1.0).contains(&total), "total score out of range: {}", total);

    let grade = ScoreGrade::from_score(total);

    ContractScore {
        spec_depth,
        falsification_coverage,
        invariant_health,
        subcontracting,
        traceability,
        total,
        grade,
    }
}

/// Spec depth: ratio of defined clauses to expected minimum.
///
/// A well-specified contract has at least 3 require, 5 ensure, 3 invariant clauses.
fn compute_spec_depth(contract: &WorkContract) -> f64 {
    if !contract.is_dbc() {
        // v4.0 flat contracts: score based on claim count vs expected 22
        let ratio = contract.claims.len() as f64 / 22.0;
        return ratio.min(1.0);
    }

    let require_score = (contract.require.len() as f64 / 3.0).min(1.0);
    let ensure_score = (contract.ensure.len() as f64 / 5.0).min(1.0);
    let invariant_score = (contract.invariant.len() as f64 / 3.0).min(1.0);

    (require_score + ensure_score + invariant_score) / 3.0
}

/// Falsification coverage: fraction of claims with verification results.
fn compute_falsification_coverage(contract: &WorkContract) -> f64 {
    if contract.claims.is_empty() {
        return 0.0;
    }

    let verified = contract
        .claims
        .iter()
        .filter(|c| c.result.is_some())
        .count();
    verified as f64 / contract.claims.len() as f64
}

/// Invariant health: pass rate across checkpoint history.
///
/// Loads all checkpoints and computes the fraction of invariants that passed.
fn compute_invariant_health(contract: &WorkContract, project_path: &Path) -> f64 {
    debug_assert!(project_path.exists(), "project_path must exist: {}", project_path.display());
    let checkpoints = CheckpointRecord::load_all(project_path, &contract.work_item_id);
    if checkpoints.is_empty() {
        // No checkpoints yet — neutral score (not penalized)
        return if contract.invariant.is_empty() {
            1.0
        } else {
            0.5
        };
    }

    let total_invariants: usize = checkpoints
        .iter()
        .map(|cp| cp.invariant_results.len())
        .sum();
    if total_invariants == 0 {
        return 1.0;
    }

    let passed: usize = checkpoints
        .iter()
        .flat_map(|cp| &cp.invariant_results)
        .filter(|r| r.passed)
        .count();

    passed as f64 / total_invariants as f64
}

/// Subcontracting score: 1.0 if postconditions are monotonically non-weakening.
fn compute_subcontracting_score(contract: &WorkContract) -> f64 {
    if contract.iteration <= 1 || contract.inherited_postconditions.is_empty() {
        return 1.0; // First iteration or no inherited — full score
    }

    match validate_subcontracting(&contract.inherited_postconditions, &contract.ensure) {
        Ok(()) => 1.0,
        Err(_) => 0.0,
    }
}

/// Traceability: coverage of the require/ensure/invariant triad.
///
/// Full traceability = all three triad legs are non-empty.
fn compute_traceability(contract: &WorkContract) -> f64 {
    if !contract.is_dbc() {
        // v4.0: traceability is binary — claims exist or not
        return if contract.claims.is_empty() { 0.0 } else { 0.8 };
    }

    let mut score: f64 = 0.0;
    if !contract.require.is_empty() {
        score += 1.0 / 3.0;
    }
    if !contract.ensure.is_empty() {
        score += 1.0 / 3.0;
    }
    if !contract.invariant.is_empty() {
        score += 1.0 / 3.0;
    }

    // Bonus for exclusion transparency (up to 1.0 total)
    if !contract.excluded_claims.is_empty() && score > 0.0 {
        score = (score + 0.05).min(1.0);
    }

    score
}

// === Drift Detection (DBC spec §13.5, §14.3) ===

/// Drift metrics for a contract — measures staleness and divergence.
///
/// Based on ABC drift bounds theorem (arXiv:2602.22302):
/// D* = alpha / gamma, where alpha = drift rate, gamma = recovery rate.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DriftMetrics {
    /// Hours since last checkpoint
    pub hours_since_checkpoint: f64,
    /// Hours since contract creation
    pub hours_since_creation: f64,
    /// Estimated drift rate alpha (0.0..1.0)
    pub drift_rate: f64,
    /// Recovery rate gamma from checkpoint frequency
    pub recovery_rate: f64,
    /// Bounded drift D* = alpha / gamma (lower is better)
    pub bounded_drift: f64,
    /// Whether the contract is considered stale
    pub is_stale: bool,
}

/// Compute drift metrics for a contract.
///
/// Staleness threshold: 24 hours without a checkpoint.
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "path_exists")]
pub fn compute_drift_metrics(contract: &WorkContract, project_path: &Path) -> DriftMetrics {
    debug_assert!(project_path.exists(), "project_path must exist: {}", project_path.display());
    let now = chrono::Utc::now();
    let hours_since_creation = (now - contract.created_at).num_minutes() as f64 / 60.0;

    let checkpoints = CheckpointRecord::load_all(project_path, &contract.work_item_id);
    let hours_since_checkpoint = if let Some(last) = checkpoints.last() {
        (now - last.timestamp).num_minutes() as f64 / 60.0
    } else {
        hours_since_creation
    };

    // Drift rate: increases with time since last checkpoint
    // alpha = min(1.0, hours_since_checkpoint / 48.0)
    let drift_rate = (hours_since_checkpoint / 48.0).min(1.0);

    // Recovery rate: based on checkpoint frequency
    // gamma = checkpoints_per_24h (capped at 1.0)
    let recovery_rate = if hours_since_creation > 0.0 {
        let cps_per_day = (checkpoints.len() as f64 / hours_since_creation) * 24.0;
        cps_per_day.min(1.0).max(0.01) // floor at 0.01 to avoid div-by-zero
    } else {
        0.01
    };

    // ABC theorem: D* = alpha / gamma
    let bounded_drift = (drift_rate / recovery_rate).min(1.0);
    let is_stale = hours_since_checkpoint > 24.0;

    DriftMetrics {
        hours_since_checkpoint,
        hours_since_creation,
        drift_rate,
        recovery_rate,
        bounded_drift,
        is_stale,
    }
}

// === Trend Tracking (DBC spec §13.6) ===

/// Point-in-time quality snapshot for trend tracking.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QualityTrendSnapshot {
    /// Timestamp of the snapshot
    pub timestamp: chrono::DateTime<chrono::Utc>,
    /// Contract score at this point
    pub score: f64,
    /// Grade at this point
    pub grade: ScoreGrade,
    /// Number of active claims
    pub active_claims: usize,
    /// Number of verified claims
    pub verified_claims: usize,
    /// Drift bound at this point
    pub bounded_drift: f64,
    /// Git SHA at snapshot time
    pub git_sha: String,
}

/// Trend analysis result with drift detection.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QualityTrend {
    /// All snapshots, sorted chronologically
    pub snapshots: Vec<QualityTrendSnapshot>,
    /// Rolling average score (7-snapshot window)
    pub rolling_average: f64,
    /// Score delta from rolling average
    pub delta_from_average: f64,
    /// Whether drift is detected (>5% drop from rolling average)
    pub drift_detected: bool,
    /// Trend direction
    pub direction: TrendDirection,
}

/// Trend direction indicator
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TrendDirection {
    Improving,
    Stable,
    Declining,
}

impl std::fmt::Display for TrendDirection {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            TrendDirection::Improving => write!(f, "improving"),
            TrendDirection::Stable => write!(f, "stable"),
            TrendDirection::Declining => write!(f, "declining"),
        }
    }
}

/// Record a quality trend snapshot to .pmat-work/{item-id}/trend/
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "path_exists")]
pub fn record_trend_snapshot(
    contract: &WorkContract,
    score: &ContractScore,
    drift: &DriftMetrics,
    git_sha: &str,
    project_path: &Path,
) -> Result<PathBuf> {
    debug_assert!(project_path.exists(), "project_path must exist: {}", project_path.display());
    let verified = contract
        .claims
        .iter()
        .filter(|c| c.result.is_some())
        .count();

    let snapshot = QualityTrendSnapshot {
        timestamp: chrono::Utc::now(),
        score: score.total,
        grade: score.grade,
        active_claims: contract.claims.len(),
        verified_claims: verified,
        bounded_drift: drift.bounded_drift,
        git_sha: git_sha.to_string(),
    };

    let trend_dir = project_path
        .join(".pmat-work")
        .join(&contract.work_item_id)
        .join("trend");
    std::fs::create_dir_all(&trend_dir)?;

    let filename = format!(
        "snapshot-{}.json",
        snapshot.timestamp.format("%Y%m%dT%H%M%S")
    );
    let path = trend_dir.join(filename);
    let json = serde_json::to_string_pretty(&snapshot)?;
    std::fs::write(&path, json)?;

    Ok(path)
}

/// Load and analyze quality trend for a work item.
///
/// Uses a 7-snapshot rolling window. Drift is detected when the current
/// score drops more than 5% below the rolling average.
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "path_exists")]
pub fn load_quality_trend(project_path: &Path, work_item_id: &str) -> QualityTrend {
    debug_assert!(project_path.exists(), "project_path must exist: {}", project_path.display());
    let trend_dir = project_path
        .join(".pmat-work")
        .join(work_item_id)
        .join("trend");

    let mut snapshots = Vec::new();
    if let Ok(entries) = std::fs::read_dir(&trend_dir) {
        for entry in entries.flatten() {
            if let Ok(content) = std::fs::read_to_string(entry.path()) {
                if let Ok(snap) = serde_json::from_str::<QualityTrendSnapshot>(&content) {
                    snapshots.push(snap);
                }
            }
        }
    }
    snapshots.sort_by_key(|s| s.timestamp);

    analyze_trend(snapshots)
}

/// Analyze a list of snapshots into a QualityTrend.
fn analyze_trend(snapshots: Vec<QualityTrendSnapshot>) -> QualityTrend {
    if snapshots.is_empty() {
        return QualityTrend {
            snapshots,
            rolling_average: 0.0,
            delta_from_average: 0.0,
            drift_detected: false,
            direction: TrendDirection::Stable,
        };
    }

    // Rolling average over last 7 snapshots
    let window_size = 7.min(snapshots.len());
    let window_start = snapshots.len() - window_size;
    let rolling_sum: f64 = snapshots[window_start..].iter().map(|s| s.score).sum();
    let rolling_average = rolling_sum / window_size as f64;

    let current_score = snapshots.last().map(|s| s.score).unwrap_or(0.0);
    let delta_from_average = current_score - rolling_average;

    // Drift: >5% drop from rolling average
    let drift_detected = delta_from_average < -0.05;

    // Direction based on last 2 snapshots
    let direction = if snapshots.len() < 2 {
        TrendDirection::Stable
    } else {
        let prev = snapshots[snapshots.len() - 2].score;
        let diff = current_score - prev;
        if diff > 0.02 {
            TrendDirection::Improving
        } else if diff < -0.02 {
            TrendDirection::Declining
        } else {
            TrendDirection::Stable
        }
    };

    QualityTrend {
        snapshots,
        rolling_average,
        delta_from_average,
        drift_detected,
        direction,
    }
}