Single-commit clean baseline after security scrub of niche-tells, project codenames, internal jargon, and contributor-email leaks. Contents: - 100 Rust crates (_primitives/_rust/) - 37 agent manifests (_manifests/) + generated specs (_generated/) - 67 user-invocable skills (skills/) - 33 hooks (hooks/) - Composition blocks (_blocks/) - Documentation (docs/, README.md) - TS adapter packages (_ts_packages/) - Assembler (_assembler/) - Roles (_roles/) - Templates (_templates/) - Forgejo CI (.forgejo/) Author: Denis Parfionovich <info@greendragon.info> License: see LICENSE.
179 lines
6.2 KiB
Rust
179 lines
6.2 KiB
Rust
//! Phase D nightly aggregation cube for `skill_invocations`.
|
|
//!
|
|
//! Constructor Pattern: one cube = aggregate-read surface. The write side
|
|
//! lives in `skill_metrics.rs`. This file stays at ≤200 LOC.
|
|
//!
|
|
//! Decision rules (thresholds per task spec):
|
|
//! - Validated : total ≥ 10 AND success_rate ≥ 0.90
|
|
//! - Archive : total ≥ 10 AND success_rate < 0.30
|
|
//! - Reextract : total ≥ 10 AND success_rate ∈ [0.30, 0.70)
|
|
//! - Insufficient: total < 10
|
|
//!
|
|
//! Times: unix-seconds (consistent with rest of ledger).
|
|
|
|
use rusqlite::{params, Connection, Result as SqlResult};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Recommendation tier for a skill based on aggregated metrics.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum SkillRecommendation {
|
|
/// ≥10 invocations AND success_rate ≥ 0.90 → mark stable.
|
|
Validated,
|
|
/// ≥10 invocations AND success_rate < 0.30 → archive.
|
|
Archive,
|
|
/// ≥10 invocations AND success_rate ∈ [0.30, 0.70) → re-derive from corpus.
|
|
Reextract,
|
|
/// < 10 invocations → wait for more data.
|
|
Insufficient,
|
|
}
|
|
|
|
/// Aggregated per-skill metrics for Phase D decision-making.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SkillAggregate {
|
|
pub skill_name: String,
|
|
pub total_invocations: u64,
|
|
/// Success rate in [0.0, 1.0]. `0.0` when total_invocations == 0.
|
|
pub success_rate: f64,
|
|
/// Median duration (p50) in milliseconds; 0 when no duration data.
|
|
pub p50_duration_ms: u64,
|
|
/// 95th-percentile duration in milliseconds; 0 when no duration data.
|
|
pub p95_duration_ms: u64,
|
|
/// Unix-second timestamp of the most-recent invocation.
|
|
pub last_invoked_ts: i64,
|
|
pub recommendation: SkillRecommendation,
|
|
}
|
|
|
|
/// Derive the recommendation tier from counts and rate.
|
|
fn recommend(total: u64, success_rate: f64) -> SkillRecommendation {
|
|
if total < 10 {
|
|
return SkillRecommendation::Insufficient;
|
|
}
|
|
if success_rate >= 0.90 {
|
|
SkillRecommendation::Validated
|
|
} else if success_rate < 0.30 {
|
|
SkillRecommendation::Archive
|
|
} else if success_rate < 0.70 {
|
|
SkillRecommendation::Reextract
|
|
} else {
|
|
// [0.70, 0.90) — not yet stable enough to validate, not bad enough to reextract
|
|
SkillRecommendation::Insufficient
|
|
}
|
|
}
|
|
|
|
/// Compute p50 and p95 for a single skill via a percentile sub-query.
|
|
///
|
|
/// SQLite lacks a native percentile aggregate, so we use NTILE-compatible
|
|
/// ORDER-BY row selection. Rows without duration_ms are excluded.
|
|
fn percentiles(
|
|
conn: &Connection,
|
|
skill_name: &str,
|
|
since_ts: Option<i64>,
|
|
) -> SqlResult<(u64, u64)> {
|
|
let cutoff = since_ts.unwrap_or(0);
|
|
let mut stmt = conn.prepare(
|
|
"SELECT duration_ms FROM skill_invocations
|
|
WHERE skill_name = ?1 AND duration_ms IS NOT NULL AND ts >= ?2
|
|
ORDER BY duration_ms ASC",
|
|
)?;
|
|
let durations: Vec<u64> = stmt
|
|
.query_map(params![skill_name, cutoff], |r| r.get::<_, i64>(0))?
|
|
.filter_map(|r| r.ok())
|
|
.map(|v| v.max(0) as u64)
|
|
.collect();
|
|
Ok(compute_percentiles(&durations))
|
|
}
|
|
|
|
/// Pure fn: index-based p50/p95 from a sorted slice.
|
|
fn compute_percentiles(sorted: &[u64]) -> (u64, u64) {
|
|
if sorted.is_empty() {
|
|
return (0, 0);
|
|
}
|
|
let n = sorted.len();
|
|
let p50 = sorted[(n - 1) / 2];
|
|
let p95_idx = ((n as f64 * 0.95).ceil() as usize).saturating_sub(1).min(n - 1);
|
|
(p50, sorted[p95_idx])
|
|
}
|
|
|
|
/// Aggregate all skills from `skill_invocations`.
|
|
///
|
|
/// `since_ts`: optional unix-second lower bound; pass `None` to include all rows.
|
|
/// Returns one `SkillAggregate` per distinct `skill_name`, sorted by
|
|
/// `success_rate` ascending (worst first — matching the markdown format).
|
|
pub fn aggregate_skills(
|
|
conn: &Connection,
|
|
since_ts: Option<i64>,
|
|
) -> SqlResult<Vec<SkillAggregate>> {
|
|
let cutoff = since_ts.unwrap_or(0);
|
|
let mut stmt = conn.prepare(
|
|
"SELECT skill_name,
|
|
COUNT(*) AS total,
|
|
COALESCE(SUM(success), 0) AS wins,
|
|
MAX(ts) AS last_ts
|
|
FROM skill_invocations
|
|
WHERE ts >= ?1
|
|
GROUP BY skill_name
|
|
ORDER BY CAST(COALESCE(SUM(success), 0) AS REAL) / COUNT(*) ASC,
|
|
skill_name ASC",
|
|
)?;
|
|
let rows = stmt.query_map(params![cutoff], |r| {
|
|
let skill_name: String = r.get(0)?;
|
|
let total: i64 = r.get(1)?;
|
|
let wins: i64 = r.get(2)?;
|
|
let last_ts: i64 = r.get(3)?;
|
|
Ok((skill_name, total, wins, last_ts))
|
|
})?;
|
|
|
|
let mut out = Vec::new();
|
|
for row in rows {
|
|
let (skill_name, total, wins, last_invoked_ts) = row?;
|
|
let total_u64 = total.max(0) as u64;
|
|
let rate = if total == 0 { 0.0 } else { wins as f64 / total as f64 };
|
|
let (p50, p95) = percentiles(conn, &skill_name, since_ts)?;
|
|
out.push(SkillAggregate {
|
|
skill_name,
|
|
total_invocations: total_u64,
|
|
success_rate: rate,
|
|
p50_duration_ms: p50,
|
|
p95_duration_ms: p95,
|
|
last_invoked_ts,
|
|
recommendation: recommend(total_u64, rate),
|
|
});
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod unit_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn recommend_tiers() {
|
|
assert_eq!(recommend(5, 0.95), SkillRecommendation::Insufficient);
|
|
assert_eq!(recommend(10, 0.95), SkillRecommendation::Validated);
|
|
assert_eq!(recommend(20, 0.25), SkillRecommendation::Archive);
|
|
assert_eq!(recommend(20, 0.55), SkillRecommendation::Reextract);
|
|
assert_eq!(recommend(20, 0.80), SkillRecommendation::Insufficient);
|
|
}
|
|
|
|
#[test]
|
|
fn percentiles_empty_slice() {
|
|
assert_eq!(compute_percentiles(&[]), (0, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn percentiles_single() {
|
|
assert_eq!(compute_percentiles(&[42]), (42, 42));
|
|
}
|
|
|
|
#[test]
|
|
fn percentiles_known_values() {
|
|
// 10 values: [10,20,30,40,50,60,70,80,90,100]
|
|
let v: Vec<u64> = (1..=10).map(|i| i * 10).collect();
|
|
let (p50, p95) = compute_percentiles(&v);
|
|
// p50 index = (10-1)/2 = 4 → 50
|
|
assert_eq!(p50, 50);
|
|
// p95 index = ceil(10*0.95)-1 = 10-1 = 9 → 100
|
|
assert_eq!(p95, 100);
|
|
}
|
|
}
|