Skip to main content

zeph_memory/store/
skills.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use super::SqliteStore;
5use crate::error::MemoryError;
6#[allow(unused_imports)]
7use zeph_db::{begin_write, sql};
8
9/// Aggregated usage statistics for a single skill, returned by [`SqliteStore::load_skill_usage`].
10#[derive(Debug)]
11pub struct SkillUsageRow {
12    /// The skill's unique name.
13    pub skill_name: String,
14    /// Total number of times this skill has been invoked.
15    pub invocation_count: i64,
16    /// ISO-8601 timestamp of the most recent invocation.
17    pub last_used_at: String,
18}
19
20/// Per-skill outcome metrics (success/failure counts) returned by [`SqliteStore::load_skill_outcome_stats`].
21#[derive(Debug)]
22pub struct SkillMetricsRow {
23    /// The skill's unique name.
24    pub skill_name: String,
25    /// The version ID this row refers to, if tracked per-version.
26    pub version_id: Option<i64>,
27    /// Total number of recorded outcomes.
28    pub total: i64,
29    /// Number of successful outcomes.
30    pub successes: i64,
31    /// Number of failed outcomes.
32    pub failures: i64,
33}
34
35/// A single persisted skill version, returned by [`SqliteStore::load_skill_versions`] and related queries.
36#[derive(Debug)]
37pub struct SkillVersionRow {
38    /// Primary key assigned by the database.
39    pub id: i64,
40    /// The skill's unique name.
41    pub skill_name: String,
42    /// Monotonically increasing version number within the skill.
43    pub version: i64,
44    /// Full SKILL.md body for this version.
45    pub body: String,
46    /// Human-readable description extracted from the skill body.
47    pub description: String,
48    /// Origin of the version: `"manual"`, `"auto"`, etc.
49    pub source: String,
50    /// Whether this version is currently the active one for the skill.
51    pub is_active: bool,
52    /// Number of successful tool invocations recorded against this version.
53    pub success_count: i64,
54    /// Number of failed tool invocations recorded against this version.
55    pub failure_count: i64,
56    /// ISO-8601 timestamp when this version was created.
57    pub created_at: String,
58}
59
60// `version`/`success_count`/`failure_count` are `INTEGER` (`INT4`) on Postgres, so they decode
61// as `i32`, not `i64`; `is_active` is `BOOLEAN` on Postgres, so it decodes as `bool` directly,
62// not `i64`. Widened/converted back to `SkillVersionRow`'s field types in the function below.
63type SkillVersionTuple = (
64    i64,
65    String,
66    i32,
67    String,
68    String,
69    String,
70    bool,
71    i32,
72    i32,
73    String,
74);
75
76fn skill_version_from_tuple(t: SkillVersionTuple) -> SkillVersionRow {
77    SkillVersionRow {
78        id: t.0,
79        skill_name: t.1,
80        version: i64::from(t.2),
81        body: t.3,
82        description: t.4,
83        source: t.5,
84        is_active: t.6,
85        success_count: i64::from(t.7),
86        failure_count: i64::from(t.8),
87        created_at: t.9,
88    }
89}
90
91impl SqliteStore {
92    /// Record usage of skills (UPSERT: increment count and update timestamp).
93    ///
94    /// All UPSERTs are issued inside a single write transaction to avoid N
95    /// round-trips and WAL contention under concurrent callers.
96    ///
97    /// # Errors
98    ///
99    /// Returns an error if the database operation fails.
100    #[tracing::instrument(skip_all, name = "memory.skills.record_skill_usage")]
101    pub async fn record_skill_usage(&self, skill_names: &[&str]) -> Result<(), MemoryError> {
102        let mut tx = begin_write(&self.pool).await?;
103        for name in skill_names {
104            // `invocation_count` on the RHS must be qualified with the table name: `PostgreSQL`'s
105            // `ON CONFLICT DO UPDATE` always exposes an `excluded` pseudo-table alongside the
106            // target table, so an unqualified self-reference is rejected as ambiguous
107            // (`column reference "invocation_count" is ambiguous`) even though only one
108            // interpretation makes sense. `SQLite` accepts the qualified form identically.
109            zeph_db::query(sql!(
110                "INSERT INTO skill_usage (skill_name, invocation_count, last_used_at) \
111                 VALUES (?, 1, CURRENT_TIMESTAMP) \
112                 ON CONFLICT(skill_name) DO UPDATE SET \
113                 invocation_count = skill_usage.invocation_count + 1, \
114                 last_used_at = CURRENT_TIMESTAMP"
115            ))
116            .bind(name)
117            .execute(&mut *tx)
118            .await?;
119        }
120        tx.commit().await?;
121        Ok(())
122    }
123
124    /// Load all skill usage statistics.
125    ///
126    /// # Errors
127    ///
128    /// Returns an error if the query fails.
129    #[tracing::instrument(skip_all, name = "memory.skills.load_skill_usage")]
130    pub async fn load_skill_usage(&self) -> Result<Vec<SkillUsageRow>, MemoryError> {
131        // `last_used_at` is `TIMESTAMPTZ` on Postgres (`TEXT` on SQLite); project through
132        // `Dialect::select_as_text` so it decodes into the `String` tuple field below.
133        // `invocation_count` is `INTEGER` (`INT4`) on Postgres, so it decodes as `i32`, not
134        // `i64`; widened back to `i64` below to keep `SkillUsageRow`'s field type unchanged.
135        let last_used_at_sel =
136            <zeph_db::ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("last_used_at");
137        let raw = format!(
138            "SELECT skill_name, invocation_count, {last_used_at_sel} \
139             FROM skill_usage ORDER BY invocation_count DESC"
140        );
141        let query_sql = zeph_db::rewrite_placeholders(&raw);
142        let rows: Vec<(String, i32, String)> = zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
143            .fetch_all(&self.pool)
144            .await?;
145
146        Ok(rows
147            .into_iter()
148            .map(
149                |(skill_name, invocation_count, last_used_at)| SkillUsageRow {
150                    skill_name,
151                    invocation_count: i64::from(invocation_count),
152                    last_used_at,
153                },
154            )
155            .collect())
156    }
157
158    /// Record a skill outcome event.
159    ///
160    /// # Errors
161    ///
162    /// Returns an error if the insert fails.
163    #[tracing::instrument(skip_all, name = "memory.skills.record_skill_outcome")]
164    pub async fn record_skill_outcome(
165        &self,
166        skill_name: &str,
167        version_id: Option<i64>,
168        conversation_id: Option<crate::types::ConversationId>,
169        outcome: &str,
170        error_context: Option<&str>,
171        outcome_detail: Option<&str>,
172    ) -> Result<(), MemoryError> {
173        zeph_db::query(sql!(
174            "INSERT INTO skill_outcomes \
175             (skill_name, version_id, conversation_id, outcome, error_context, outcome_detail) \
176             VALUES (?, ?, ?, ?, ?, ?)"
177        ))
178        .bind(skill_name)
179        .bind(version_id)
180        .bind(conversation_id)
181        .bind(outcome)
182        .bind(error_context)
183        .bind(outcome_detail)
184        .execute(&self.pool)
185        .await?;
186        Ok(())
187    }
188
189    /// Record outcomes for multiple skills in a single transaction.
190    ///
191    /// # Errors
192    ///
193    /// Returns an error if any insert fails (whole batch is rolled back).
194    #[tracing::instrument(skip_all, name = "memory.skills.record_skill_outcomes_batch")]
195    pub async fn record_skill_outcomes_batch(
196        &self,
197        skill_names: &[String],
198        conversation_id: Option<crate::types::ConversationId>,
199        outcome: &str,
200        error_context: Option<&str>,
201        outcome_detail: Option<&str>,
202    ) -> Result<(), MemoryError> {
203        // Acquire the write lock up front to avoid DEFERRED read->write upgrades
204        // failing with SQLITE_BUSY_SNAPSHOT under concurrent WAL writers.
205        let mut tx = begin_write(&self.pool).await?;
206
207        let mut version_map: std::collections::HashMap<String, Option<i64>> =
208            std::collections::HashMap::new();
209        for name in skill_names {
210            let vid: Option<(i64,)> = zeph_db::query_as(sql!(
211                "SELECT id FROM skill_versions WHERE skill_name = ? AND is_active = TRUE"
212            ))
213            .bind(name)
214            .fetch_optional(&mut *tx)
215            .await?;
216            version_map.insert(name.clone(), vid.map(|r| r.0));
217        }
218
219        for name in skill_names {
220            let version_id = version_map.get(name.as_str()).copied().flatten();
221            zeph_db::query(sql!(
222                "INSERT INTO skill_outcomes \
223                 (skill_name, version_id, conversation_id, outcome, error_context, outcome_detail) \
224                 VALUES (?, ?, ?, ?, ?, ?)"
225            ))
226            .bind(name)
227            .bind(version_id)
228            .bind(conversation_id)
229            .bind(outcome)
230            .bind(error_context)
231            .bind(outcome_detail)
232            .execute(&mut *tx)
233            .await?;
234        }
235        tx.commit().await?;
236        Ok(())
237    }
238
239    /// Load metrics for a skill (latest version group).
240    ///
241    /// # Errors
242    ///
243    /// Returns an error if the query fails.
244    #[tracing::instrument(skip_all, name = "memory.skills.skill_metrics")]
245    pub async fn skill_metrics(
246        &self,
247        skill_name: &str,
248    ) -> Result<Option<SkillMetricsRow>, MemoryError> {
249        let row: Option<(String, Option<i64>, i64, i64, i64)> = zeph_db::query_as(sql!(
250            "SELECT skill_name, version_id, \
251             COUNT(*) as total, \
252             SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as successes, \
253             COUNT(*) - SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as failures \
254             FROM skill_outcomes WHERE skill_name = ? \
255             AND outcome NOT IN ('user_approval', 'user_rejection') \
256             GROUP BY skill_name, version_id \
257             ORDER BY version_id DESC LIMIT 1"
258        ))
259        .bind(skill_name)
260        .fetch_optional(&self.pool)
261        .await?;
262
263        Ok(row.map(
264            |(skill_name, version_id, total, successes, failures)| SkillMetricsRow {
265                skill_name,
266                version_id,
267                total,
268                successes,
269                failures,
270            },
271        ))
272    }
273
274    /// Load all skill outcome stats grouped by skill name.
275    ///
276    /// # Errors
277    ///
278    /// Returns an error if the query fails.
279    #[tracing::instrument(skip_all, name = "memory.skills.load_skill_outcome_stats")]
280    pub async fn load_skill_outcome_stats(&self) -> Result<Vec<SkillMetricsRow>, MemoryError> {
281        let rows: Vec<(String, Option<i64>, i64, i64, i64)> = zeph_db::query_as(sql!(
282            "SELECT skill_name, version_id, \
283             COUNT(*) as total, \
284             SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as successes, \
285             COUNT(*) - SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as failures \
286             FROM skill_outcomes \
287             GROUP BY skill_name \
288             ORDER BY total DESC"
289        ))
290        .fetch_all(&self.pool)
291        .await?;
292
293        Ok(rows
294            .into_iter()
295            .map(
296                |(skill_name, version_id, total, successes, failures)| SkillMetricsRow {
297                    skill_name,
298                    version_id,
299                    total,
300                    successes,
301                    failures,
302                },
303            )
304            .collect())
305    }
306
307    /// Save a new skill version and return its ID.
308    ///
309    /// # Errors
310    ///
311    /// Returns an error if the insert fails.
312    #[allow(clippy::too_many_arguments)]
313    // function with many required inputs; a *Params struct would be more verbose without simplifying the call site
314    #[tracing::instrument(skip_all, name = "memory.skills.save_skill_version")]
315    pub async fn save_skill_version(
316        &self,
317        skill_name: &str,
318        version: i64,
319        body: &str,
320        description: &str,
321        source: &str,
322        error_context: Option<&str>,
323        predecessor_id: Option<i64>,
324    ) -> Result<i64, MemoryError> {
325        let row: (i64,) = zeph_db::query_as(sql!(
326            "INSERT INTO skill_versions \
327             (skill_name, version, body, description, source, error_context, predecessor_id) \
328             VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id"
329        ))
330        .bind(skill_name)
331        .bind(version)
332        .bind(body)
333        .bind(description)
334        .bind(source)
335        .bind(error_context)
336        .bind(predecessor_id)
337        .fetch_one(&self.pool)
338        .await?;
339        Ok(row.0)
340    }
341
342    /// Save a new skill version and atomically activate it within a single `BEGIN IMMEDIATE`
343    /// transaction, preventing orphaned saved-but-inactive versions on crash or concurrent access.
344    ///
345    /// Equivalent to calling [`Self::save_skill_version`] followed by [`Self::activate_skill_version`] but
346    /// without the window between the two calls where the DB can be left in an inconsistent state.
347    ///
348    /// # Errors
349    ///
350    /// Returns an error if the insert, deactivate, or activate queries fail. The transaction is
351    /// rolled back automatically on error.
352    #[allow(clippy::too_many_arguments)]
353    #[tracing::instrument(skip_all, name = "memory.skills.save_and_activate_skill_version")]
354    pub async fn save_and_activate_skill_version(
355        &self,
356        skill_name: &str,
357        version: i64,
358        body: &str,
359        description: &str,
360        source: &str,
361        error_context: Option<&str>,
362        predecessor_id: Option<i64>,
363    ) -> Result<i64, MemoryError> {
364        let mut tx = begin_write(&self.pool).await?;
365
366        let row: (i64,) = zeph_db::query_as(sql!(
367            "INSERT INTO skill_versions \
368             (skill_name, version, body, description, source, error_context, predecessor_id) \
369             VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id"
370        ))
371        .bind(skill_name)
372        .bind(version)
373        .bind(body)
374        .bind(description)
375        .bind(source)
376        .bind(error_context)
377        .bind(predecessor_id)
378        .fetch_one(&mut *tx)
379        .await?;
380        let id = row.0;
381
382        switch_active_skill_version(&mut tx, skill_name, id).await?;
383
384        tx.commit().await?;
385        Ok(id)
386    }
387
388    /// Count the number of distinct conversation sessions in which a skill produced an outcome.
389    ///
390    /// Uses `COUNT(DISTINCT conversation_id)` from `skill_outcomes`. Rows where
391    /// `conversation_id IS NULL` are excluded (legacy rows without session tracking).
392    ///
393    /// # Errors
394    ///
395    /// Returns an error if the query fails.
396    #[tracing::instrument(skip_all, name = "memory.skills.distinct_session_count")]
397    pub async fn distinct_session_count(&self, skill_name: &str) -> Result<i64, MemoryError> {
398        let row: (i64,) = zeph_db::query_as(sql!(
399            "SELECT COUNT(DISTINCT conversation_id) FROM skill_outcomes \
400             WHERE skill_name = ? AND conversation_id IS NOT NULL"
401        ))
402        .bind(skill_name)
403        .fetch_one(&self.pool)
404        .await?;
405        Ok(row.0)
406    }
407
408    /// Load the active version for a skill.
409    ///
410    /// # Errors
411    ///
412    /// Returns an error if the query fails.
413    #[tracing::instrument(skip_all, name = "memory.skills.active_skill_version")]
414    pub async fn active_skill_version(
415        &self,
416        skill_name: &str,
417    ) -> Result<Option<SkillVersionRow>, MemoryError> {
418        // `created_at` is `TIMESTAMPTZ` on Postgres (`TEXT` on SQLite); project through
419        // `Dialect::select_as_text` so it decodes into the `String` tuple field below.
420        let created_at_sel =
421            <zeph_db::ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("created_at");
422        let raw = format!(
423            "SELECT id, skill_name, version, body, description, source, \
424                 is_active, success_count, failure_count, {created_at_sel} \
425                 FROM skill_versions WHERE skill_name = ? AND is_active = TRUE LIMIT 1"
426        );
427        let query_sql = zeph_db::rewrite_placeholders(&raw);
428        let row: Option<SkillVersionTuple> = zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
429            .bind(skill_name)
430            .fetch_optional(&self.pool)
431            .await?;
432
433        Ok(row.map(skill_version_from_tuple))
434    }
435
436    /// Activate a specific version (deactivates others for the same skill).
437    ///
438    /// # Errors
439    ///
440    /// Returns an error if the update fails.
441    #[tracing::instrument(skip_all, name = "memory.skills.activate_skill_version")]
442    pub async fn activate_skill_version(
443        &self,
444        skill_name: &str,
445        version_id: i64,
446    ) -> Result<(), MemoryError> {
447        let mut tx = begin_write(&self.pool).await?;
448
449        switch_active_skill_version(&mut tx, skill_name, version_id).await?;
450
451        tx.commit().await?;
452        Ok(())
453    }
454
455    /// Get the next version number for a skill.
456    ///
457    /// # Errors
458    ///
459    /// Returns an error if the query fails.
460    #[tracing::instrument(skip_all, name = "memory.skills.next_skill_version")]
461    pub async fn next_skill_version(&self, skill_name: &str) -> Result<i64, MemoryError> {
462        let row: (i64,) = zeph_db::query_as(sql!(
463            "SELECT COALESCE(MAX(version), 0) + 1 FROM skill_versions WHERE skill_name = ?"
464        ))
465        .bind(skill_name)
466        .fetch_one(&self.pool)
467        .await?;
468        Ok(row.0)
469    }
470
471    /// Get the latest auto-generated version's `created_at` for cooldown check.
472    ///
473    /// # Errors
474    ///
475    /// Returns an error if the query fails.
476    #[tracing::instrument(skip_all, name = "memory.skills.last_improvement_time")]
477    pub async fn last_improvement_time(
478        &self,
479        skill_name: &str,
480    ) -> Result<Option<String>, MemoryError> {
481        // `created_at` is `TIMESTAMPTZ` on Postgres — see `active_skill_version`.
482        let created_at_sel =
483            <zeph_db::ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("created_at");
484        let raw = format!(
485            "SELECT {created_at_sel} FROM skill_versions \
486             WHERE skill_name = ? AND source = 'auto' \
487             ORDER BY id DESC LIMIT 1"
488        );
489        let query_sql = zeph_db::rewrite_placeholders(&raw);
490        let row: Option<(String,)> = zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
491            .bind(skill_name)
492            .fetch_optional(&self.pool)
493            .await?;
494        Ok(row.map(|r| r.0))
495    }
496
497    /// Ensure a base (v1 manual) version exists for a skill. Idempotent.
498    ///
499    /// The check-then-insert-then-activate sequence runs inside a single write
500    /// transaction so that two concurrent callers cannot both observe
501    /// `existing.is_none()` and race to insert duplicate v1 rows.
502    ///
503    /// # Errors
504    ///
505    /// Returns an error if the DB operation fails.
506    #[tracing::instrument(skip_all, name = "memory.skills.ensure_skill_version_exists")]
507    pub async fn ensure_skill_version_exists(
508        &self,
509        skill_name: &str,
510        body: &str,
511        description: &str,
512    ) -> Result<(), MemoryError> {
513        let mut tx = begin_write(&self.pool).await?;
514
515        let existing: Option<(i64,)> = zeph_db::query_as(sql!(
516            "SELECT id FROM skill_versions WHERE skill_name = ? LIMIT 1"
517        ))
518        .bind(skill_name)
519        .fetch_optional(&mut *tx)
520        .await?;
521
522        if existing.is_none() {
523            let row: (i64,) = zeph_db::query_as(sql!(
524                "INSERT INTO skill_versions \
525                 (skill_name, version, body, description, source, error_context, predecessor_id) \
526                 VALUES (?, 1, ?, ?, 'manual', NULL, NULL) RETURNING id"
527            ))
528            .bind(skill_name)
529            .bind(body)
530            .bind(description)
531            .fetch_one(&mut *tx)
532            .await?;
533            let id = row.0;
534
535            switch_active_skill_version(&mut tx, skill_name, id).await?;
536        }
537
538        tx.commit().await?;
539        Ok(())
540    }
541
542    /// Load all versions for a skill, ordered by version number.
543    ///
544    /// # Errors
545    ///
546    /// Returns an error if the query fails.
547    #[tracing::instrument(skip_all, name = "memory.skills.load_skill_versions")]
548    pub async fn load_skill_versions(
549        &self,
550        skill_name: &str,
551    ) -> Result<Vec<SkillVersionRow>, MemoryError> {
552        // `created_at` is `TIMESTAMPTZ` on Postgres — see `active_skill_version`.
553        let created_at_sel =
554            <zeph_db::ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("created_at");
555        let raw = format!(
556            "SELECT id, skill_name, version, body, description, source, \
557                 is_active, success_count, failure_count, {created_at_sel} \
558                 FROM skill_versions WHERE skill_name = ? ORDER BY version ASC"
559        );
560        let query_sql = zeph_db::rewrite_placeholders(&raw);
561        let rows: Vec<SkillVersionTuple> = zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
562            .bind(skill_name)
563            .fetch_all(&self.pool)
564            .await?;
565
566        Ok(rows.into_iter().map(skill_version_from_tuple).collect())
567    }
568
569    /// Count auto-generated versions for a skill.
570    ///
571    /// # Errors
572    ///
573    /// Returns an error if the query fails.
574    #[tracing::instrument(skip_all, name = "memory.skills.count_auto_versions")]
575    pub async fn count_auto_versions(&self, skill_name: &str) -> Result<i64, MemoryError> {
576        let row: (i64,) = zeph_db::query_as(sql!(
577            "SELECT COUNT(*) FROM skill_versions WHERE skill_name = ? AND source = 'auto'"
578        ))
579        .bind(skill_name)
580        .fetch_one(&self.pool)
581        .await?;
582        Ok(row.0)
583    }
584
585    /// Delete oldest non-active auto versions exceeding max limit.
586    /// Returns the number of pruned versions.
587    ///
588    /// # Errors
589    ///
590    /// Returns an error if the delete fails.
591    #[cfg(feature = "sqlite")]
592    #[tracing::instrument(skip_all, name = "memory.skills.prune_skill_versions")]
593    pub async fn prune_skill_versions(
594        &self,
595        skill_name: &str,
596        max_versions: u32,
597    ) -> Result<u32, MemoryError> {
598        let result = zeph_db::query(sql!(
599            "DELETE FROM skill_versions WHERE id IN (\
600                SELECT id FROM skill_versions \
601                WHERE skill_name = ? AND source = 'auto' AND is_active = FALSE \
602                ORDER BY id ASC \
603                LIMIT max(0, (SELECT COUNT(*) FROM skill_versions \
604                    WHERE skill_name = ? AND source = 'auto') - ?)\
605            )"
606        ))
607        .bind(skill_name)
608        .bind(skill_name)
609        .bind(max_versions)
610        .execute(&self.pool)
611        .await?;
612        Ok(u32::try_from(result.rows_affected()).unwrap_or(0))
613    }
614
615    /// Prune old auto-generated skill versions, keeping only the most recent `max_versions`.
616    ///
617    /// # Errors
618    ///
619    /// Returns an error if the database query fails.
620    #[cfg(feature = "postgres")]
621    #[tracing::instrument(skip_all, name = "memory.skills.prune_skill_versions")]
622    pub async fn prune_skill_versions(
623        &self,
624        skill_name: &str,
625        max_versions: u32,
626    ) -> Result<u32, MemoryError> {
627        let result = zeph_db::query(sql!(
628            "DELETE FROM skill_versions WHERE id IN (\
629                SELECT id FROM skill_versions \
630                WHERE skill_name = ? AND source = 'auto' AND is_active = FALSE \
631                ORDER BY id DESC \
632                OFFSET ?\
633            )"
634        ))
635        .bind(skill_name)
636        .bind(i64::from(max_versions))
637        .execute(&self.pool)
638        .await?;
639        Ok(u32::try_from(result.rows_affected()).unwrap_or(0))
640    }
641
642    /// Get the predecessor version for rollback.
643    ///
644    /// # Errors
645    ///
646    /// Returns an error if the query fails.
647    #[tracing::instrument(skip_all, name = "memory.skills.predecessor_version")]
648    pub async fn predecessor_version(
649        &self,
650        version_id: i64,
651    ) -> Result<Option<SkillVersionRow>, MemoryError> {
652        let pred_id: Option<(Option<i64>,)> = zeph_db::query_as(sql!(
653            "SELECT predecessor_id FROM skill_versions WHERE id = ?"
654        ))
655        .bind(version_id)
656        .fetch_optional(&self.pool)
657        .await?;
658
659        let Some((Some(pid),)) = pred_id else {
660            return Ok(None);
661        };
662
663        // `created_at` is `TIMESTAMPTZ` on Postgres — see `active_skill_version`.
664        let created_at_sel =
665            <zeph_db::ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("created_at");
666        let raw = format!(
667            "SELECT id, skill_name, version, body, description, source, \
668                 is_active, success_count, failure_count, {created_at_sel} \
669                 FROM skill_versions WHERE id = ?"
670        );
671        let query_sql = zeph_db::rewrite_placeholders(&raw);
672        let row: Option<SkillVersionTuple> = zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
673            .bind(pid)
674            .fetch_optional(&self.pool)
675            .await?;
676
677        Ok(row.map(skill_version_from_tuple))
678    }
679
680    /// Return the skill names for all currently active auto-generated versions.
681    ///
682    /// Used to check rollback eligibility at the start of each agent turn.
683    ///
684    /// # Errors
685    /// Returns [`MemoryError`] on `SQLite` query failure.
686    #[tracing::instrument(skip_all, name = "memory.skills.list_active_auto_versions")]
687    pub async fn list_active_auto_versions(&self) -> Result<Vec<String>, MemoryError> {
688        let rows: Vec<(String,)> = zeph_db::query_as(sql!(
689            "SELECT skill_name FROM skill_versions WHERE is_active = TRUE AND source = 'auto'"
690        ))
691        .fetch_all(&self.pool)
692        .await?;
693        Ok(rows.into_iter().map(|(name,)| name).collect())
694    }
695
696    // --- STEM: skill_usage_log queries ---
697
698    /// Insert a tool usage log entry.
699    ///
700    /// `tool_sequence` must be a normalized compact JSON array (see `stem::normalize_tool_sequence`).
701    /// `sequence_hash` is the 16-char blake3 hex of `tool_sequence`.
702    ///
703    /// # Errors
704    /// Returns [`MemoryError`] on insert failure.
705    #[tracing::instrument(skip_all, name = "memory.skills.insert_tool_usage_log")]
706    pub async fn insert_tool_usage_log(
707        &self,
708        tool_sequence: &str,
709        sequence_hash: &str,
710        context_hash: &str,
711        outcome: &str,
712        conversation_id: Option<crate::types::ConversationId>,
713    ) -> Result<(), MemoryError> {
714        zeph_db::query(sql!(
715            "INSERT INTO skill_usage_log \
716             (tool_sequence, sequence_hash, context_hash, outcome, conversation_id) \
717             VALUES (?, ?, ?, ?, ?)"
718        ))
719        .bind(tool_sequence)
720        .bind(sequence_hash)
721        .bind(context_hash)
722        .bind(outcome)
723        .bind(conversation_id)
724        .execute(&self.pool)
725        .await?;
726        Ok(())
727    }
728
729    /// Find tool sequences that have been seen at least `min_count` times within the last
730    /// `window_days` days.
731    ///
732    /// Returns `(tool_sequence, sequence_hash, occurrence_count, success_count)` tuples.
733    ///
734    /// # Errors
735    /// Returns [`MemoryError`] on query failure.
736    #[cfg(feature = "sqlite")]
737    #[tracing::instrument(skip_all, name = "memory.skills.find_recurring_patterns")]
738    pub async fn find_recurring_patterns(
739        &self,
740        min_count: u32,
741        window_days: u32,
742    ) -> Result<Vec<(String, String, u32, u32)>, MemoryError> {
743        let rows: Vec<(String, String, i64, i64)> = zeph_db::query_as(sql!(
744            "SELECT tool_sequence, sequence_hash, \
745                    COUNT(*) as occurrence_count, \
746                    SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as success_count \
747             FROM skill_usage_log \
748             WHERE created_at > datetime('now', '-' || ? || ' days') \
749             GROUP BY sequence_hash \
750             HAVING occurrence_count >= ? \
751             ORDER BY occurrence_count DESC \
752             LIMIT 10"
753        ))
754        .bind(window_days)
755        .bind(min_count)
756        .fetch_all(&self.pool)
757        .await?;
758
759        Ok(rows
760            .into_iter()
761            .map(|(seq, hash, occ, suc)| {
762                (
763                    seq,
764                    hash,
765                    u32::try_from(occ).unwrap_or(u32::MAX),
766                    u32::try_from(suc).unwrap_or(0),
767                )
768            })
769            .collect())
770    }
771
772    /// Find tool+skill combinations used at least `min_count` times within `window_days` days.
773    ///
774    /// # Errors
775    ///
776    /// Returns an error if the database query fails.
777    #[cfg(feature = "postgres")]
778    #[tracing::instrument(skip_all, name = "memory.skills.find_recurring_patterns")]
779    pub async fn find_recurring_patterns(
780        &self,
781        min_count: u32,
782        window_days: u32,
783    ) -> Result<Vec<(String, String, u32, u32)>, MemoryError> {
784        let rows: Vec<(String, String, i64, i64)> = zeph_db::query_as(sql!(
785            "SELECT tool_sequence, sequence_hash, \
786                    COUNT(*) as occurrence_count, \
787                    SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as success_count \
788             FROM skill_usage_log \
789             WHERE created_at > NOW() - INTERVAL '1 day' * ? \
790             GROUP BY sequence_hash, tool_sequence \
791             HAVING COUNT(*) >= ? \
792             ORDER BY occurrence_count DESC \
793             LIMIT 10"
794        ))
795        .bind(i64::from(window_days))
796        .bind(i64::from(min_count))
797        .fetch_all(&self.pool)
798        .await?;
799
800        Ok(rows
801            .into_iter()
802            .map(|(seq, hash, occ, suc)| {
803                (
804                    seq,
805                    hash,
806                    u32::try_from(occ).unwrap_or(u32::MAX),
807                    u32::try_from(suc).unwrap_or(0),
808                )
809            })
810            .collect())
811    }
812
813    /// Delete `skill_usage_log` rows older than `retention_days` days.
814    ///
815    /// # Errors
816    /// Returns [`MemoryError`] on delete failure.
817    #[cfg(feature = "sqlite")]
818    #[tracing::instrument(skip_all, name = "memory.skills.prune_tool_usage_log")]
819    pub async fn prune_tool_usage_log(&self, retention_days: u32) -> Result<u64, MemoryError> {
820        let result = zeph_db::query(sql!(
821            "DELETE FROM skill_usage_log \
822             WHERE created_at < datetime('now', '-' || ? || ' days')"
823        ))
824        .bind(retention_days)
825        .execute(&self.pool)
826        .await?;
827        Ok(result.rows_affected())
828    }
829
830    /// Delete tool usage log entries older than `retention_days` days.
831    ///
832    /// # Errors
833    ///
834    /// Returns an error if the database query fails.
835    #[cfg(feature = "postgres")]
836    #[tracing::instrument(skip_all, name = "memory.skills.prune_tool_usage_log")]
837    pub async fn prune_tool_usage_log(&self, retention_days: u32) -> Result<u64, MemoryError> {
838        let result = zeph_db::query(sql!(
839            "DELETE FROM skill_usage_log \
840             WHERE created_at < NOW() - INTERVAL '1 day' * ?"
841        ))
842        .bind(i64::from(retention_days))
843        .execute(&self.pool)
844        .await?;
845        Ok(result.rows_affected())
846    }
847
848    // --- ERL: skill_heuristics queries ---
849
850    /// Insert a new heuristic (no dedup — caller must check first).
851    ///
852    /// # Errors
853    /// Returns [`MemoryError`] on insert failure.
854    #[tracing::instrument(skip_all, name = "memory.skills.insert_skill_heuristic")]
855    pub async fn insert_skill_heuristic(
856        &self,
857        skill_name: Option<&str>,
858        heuristic_text: &str,
859        confidence: f64,
860    ) -> Result<i64, MemoryError> {
861        let row: (i64,) = zeph_db::query_as(sql!(
862            "INSERT INTO skill_heuristics (skill_name, heuristic_text, confidence) \
863             VALUES (?, ?, ?) RETURNING id"
864        ))
865        .bind(skill_name)
866        .bind(heuristic_text)
867        .bind(confidence)
868        .fetch_one(&self.pool)
869        .await?;
870        Ok(row.0)
871    }
872
873    /// Increment `use_count` and update `updated_at` for an existing heuristic by ID.
874    ///
875    /// # Errors
876    /// Returns [`MemoryError`] on update failure.
877    #[cfg(feature = "sqlite")]
878    #[tracing::instrument(skip_all, name = "memory.skills.increment_heuristic_use_count")]
879    pub async fn increment_heuristic_use_count(&self, id: i64) -> Result<(), MemoryError> {
880        zeph_db::query(sql!(
881            "UPDATE skill_heuristics \
882             SET use_count = use_count + 1, updated_at = datetime('now') \
883             WHERE id = ?"
884        ))
885        .bind(id)
886        .execute(&self.pool)
887        .await?;
888        Ok(())
889    }
890
891    /// Increment `use_count` and update `updated_at` for an existing heuristic by ID.
892    ///
893    /// # Errors
894    /// Returns [`MemoryError`] on update failure.
895    #[cfg(feature = "postgres")]
896    #[tracing::instrument(skip_all, name = "memory.skills.increment_heuristic_use_count")]
897    pub async fn increment_heuristic_use_count(&self, id: i64) -> Result<(), MemoryError> {
898        zeph_db::query(sql!(
899            "UPDATE skill_heuristics \
900             SET use_count = use_count + 1, updated_at = CURRENT_TIMESTAMP \
901             WHERE id = $1"
902        ))
903        .bind(id)
904        .execute(&self.pool)
905        .await?;
906        Ok(())
907    }
908
909    /// Load heuristics for a given skill (exact match + NULL/general), ordered by confidence DESC.
910    ///
911    /// Returns `(id, heuristic_text, confidence, use_count)` tuples.
912    /// At most `limit` rows are returned.
913    ///
914    /// # Errors
915    /// Returns [`MemoryError`] on query failure.
916    #[tracing::instrument(skip_all, name = "memory.skills.load_skill_heuristics")]
917    pub async fn load_skill_heuristics(
918        &self,
919        skill_name: &str,
920        min_confidence: f64,
921        limit: u32,
922    ) -> Result<Vec<(i64, String, f64, i64)>, MemoryError> {
923        let rows: Vec<(i64, String, f64, i64)> = zeph_db::query_as(sql!(
924            "SELECT id, heuristic_text, confidence, use_count \
925             FROM skill_heuristics \
926             WHERE (skill_name = ? OR skill_name IS NULL) \
927               AND confidence >= ? \
928             ORDER BY confidence DESC \
929             LIMIT ?"
930        ))
931        .bind(skill_name)
932        .bind(min_confidence)
933        .bind(i64::from(limit))
934        .fetch_all(&self.pool)
935        .await?;
936        Ok(rows)
937    }
938
939    /// Load all heuristics for a skill (for dedup checks), without confidence filter.
940    ///
941    /// Returns `(id, heuristic_text)` tuples.
942    ///
943    /// # Errors
944    /// Returns [`MemoryError`] on query failure.
945    #[tracing::instrument(skip_all, name = "memory.skills.load_all_heuristics_for_skill")]
946    pub async fn load_all_heuristics_for_skill(
947        &self,
948        skill_name: Option<&str>,
949    ) -> Result<Vec<(i64, String)>, MemoryError> {
950        let rows: Vec<(i64, String)> = zeph_db::query_as(sql!(
951            "SELECT id, heuristic_text FROM skill_heuristics \
952             WHERE (skill_name = ? OR (? IS NULL AND skill_name IS NULL))"
953        ))
954        .bind(skill_name)
955        .bind(skill_name)
956        .fetch_all(&self.pool)
957        .await?;
958        Ok(rows)
959    }
960
961    // --- D2Skill: step-level error corrections ---
962
963    /// Find matching step corrections for a tool failure.
964    ///
965    /// Returns `(id, hint)` pairs ordered by `success_count DESC, use_count DESC`.
966    /// Uses `INSTR` instead of LIKE to avoid wildcard injection from `error_context`.
967    ///
968    /// # Errors
969    ///
970    /// Returns [`MemoryError`] on query failure.
971    #[cfg(feature = "sqlite")]
972    #[tracing::instrument(skip_all, name = "memory.skills.find_step_corrections")]
973    pub async fn find_step_corrections(
974        &self,
975        skill_name: &str,
976        failure_kind: &str,
977        error_context: &str,
978        tool_name: &str,
979        limit: u32,
980    ) -> Result<Vec<(i64, String)>, MemoryError> {
981        let rows: Vec<(i64, String)> = zeph_db::query_as(sql!(
982            "SELECT id, hint FROM step_corrections \
983             WHERE skill_name = ? \
984               AND (failure_kind = '' OR failure_kind = ?) \
985               AND (error_substring = '' OR INSTR(?, error_substring) > 0) \
986               AND (tool_name = '' OR tool_name = ?) \
987             ORDER BY success_count DESC, use_count DESC \
988             LIMIT ?"
989        ))
990        .bind(skill_name)
991        .bind(failure_kind)
992        .bind(error_context)
993        .bind(tool_name)
994        .bind(limit)
995        .fetch_all(&self.pool)
996        .await?;
997        Ok(rows)
998    }
999
1000    /// Find corrections that match the given skill, failure kind, error context, and tool name.
1001    ///
1002    /// # Errors
1003    ///
1004    /// Returns an error if the database query fails.
1005    #[cfg(feature = "postgres")]
1006    #[tracing::instrument(skip_all, name = "memory.skills.find_step_corrections")]
1007    pub async fn find_step_corrections(
1008        &self,
1009        skill_name: &str,
1010        failure_kind: &str,
1011        error_context: &str,
1012        tool_name: &str,
1013        limit: u32,
1014    ) -> Result<Vec<(i64, String)>, MemoryError> {
1015        let rows: Vec<(i64, String)> = zeph_db::query_as(sql!(
1016            "SELECT id, hint FROM step_corrections \
1017             WHERE skill_name = ? \
1018               AND (failure_kind = '' OR failure_kind = ?) \
1019               AND (error_substring = '' OR strpos(?, error_substring) > 0) \
1020               AND (tool_name = '' OR tool_name = ?) \
1021             ORDER BY success_count DESC, use_count DESC \
1022             LIMIT ?"
1023        ))
1024        .bind(skill_name)
1025        .bind(failure_kind)
1026        .bind(error_context)
1027        .bind(tool_name)
1028        .bind(i64::from(limit))
1029        .fetch_all(&self.pool)
1030        .await?;
1031        Ok(rows)
1032    }
1033
1034    /// Insert a new step correction (from ARISE trace analysis).
1035    ///
1036    /// Duplicate `(skill_name, failure_kind, error_substring, tool_name)` tuples are silently
1037    /// ignored (handled by `ON CONFLICT IGNORE` in the schema).
1038    ///
1039    /// # Errors
1040    ///
1041    /// Returns [`MemoryError`] on query failure.
1042    #[tracing::instrument(skip_all, name = "memory.skills.insert_step_correction")]
1043    pub async fn insert_step_correction(
1044        &self,
1045        skill_name: &str,
1046        failure_kind: &str,
1047        error_substring: &str,
1048        tool_name: &str,
1049        hint: &str,
1050    ) -> Result<(), MemoryError> {
1051        zeph_db::query(sql!(
1052            "INSERT INTO step_corrections \
1053             (skill_name, failure_kind, error_substring, tool_name, hint) \
1054             VALUES (?, ?, ?, ?, ?) \
1055             ON CONFLICT (skill_name, failure_kind, error_substring, tool_name) DO NOTHING"
1056        ))
1057        .bind(skill_name)
1058        .bind(failure_kind)
1059        .bind(error_substring)
1060        .bind(tool_name)
1061        .bind(hint)
1062        .execute(&self.pool)
1063        .await?;
1064        Ok(())
1065    }
1066
1067    /// Increment `use_count` and optionally `success_count` for a step correction.
1068    ///
1069    /// # Errors
1070    ///
1071    /// Returns [`MemoryError`] on query failure.
1072    #[tracing::instrument(skip_all, name = "memory.skills.record_correction_usage")]
1073    pub async fn record_correction_usage(
1074        &self,
1075        correction_id: i64,
1076        was_successful: bool,
1077    ) -> Result<(), MemoryError> {
1078        if was_successful {
1079            zeph_db::query(sql!(
1080                "UPDATE step_corrections \
1081                 SET use_count = use_count + 1, success_count = success_count + 1 \
1082                 WHERE id = ?"
1083            ))
1084            .bind(correction_id)
1085            .execute(&self.pool)
1086            .await?;
1087        } else {
1088            zeph_db::query(sql!(
1089                "UPDATE step_corrections SET use_count = use_count + 1 WHERE id = ?"
1090            ))
1091            .bind(correction_id)
1092            .execute(&self.pool)
1093            .await?;
1094        }
1095        Ok(())
1096    }
1097
1098    // --- SkillOrchestra: RL routing head weight persistence ---
1099
1100    /// Load routing head weights blob from the singleton row.
1101    ///
1102    /// Returns `(embed_dim, weights_bytes, baseline, update_count)` or `None` if not yet trained.
1103    ///
1104    /// # Errors
1105    ///
1106    /// Returns [`MemoryError`] on query failure.
1107    #[tracing::instrument(skip_all, name = "memory.skills.load_routing_head_weights")]
1108    #[allow(clippy::type_complexity)]
1109    pub async fn load_routing_head_weights(
1110        &self,
1111    ) -> Result<Option<(i64, Vec<u8>, f64, i64)>, MemoryError> {
1112        let row: Option<(i64, Vec<u8>, f64, i64)> = zeph_db::query_as(sql!(
1113            "SELECT embed_dim, weights, baseline, update_count \
1114             FROM routing_head_weights WHERE id = 1"
1115        ))
1116        .fetch_optional(&self.pool)
1117        .await?;
1118        Ok(row)
1119    }
1120
1121    /// Persist routing head weights (upsert singleton row).
1122    ///
1123    /// # Errors
1124    ///
1125    /// Returns [`MemoryError`] on query failure.
1126    #[cfg(feature = "sqlite")]
1127    #[tracing::instrument(skip_all, name = "memory.skills.save_routing_head_weights")]
1128    pub async fn save_routing_head_weights(
1129        &self,
1130        embed_dim: i64,
1131        weights: &[u8],
1132        baseline: f64,
1133        update_count: i64,
1134    ) -> Result<(), MemoryError> {
1135        zeph_db::query(sql!(
1136            "INSERT INTO routing_head_weights (id, embed_dim, weights, baseline, update_count, updated_at) \
1137             VALUES (1, ?, ?, ?, ?, datetime('now')) \
1138             ON CONFLICT(id) DO UPDATE SET \
1139               embed_dim = excluded.embed_dim, \
1140               weights = excluded.weights, \
1141               baseline = excluded.baseline, \
1142               update_count = excluded.update_count, \
1143               updated_at = datetime('now')"
1144        ))
1145        .bind(embed_dim)
1146        .bind(weights)
1147        .bind(baseline)
1148        .bind(update_count)
1149        .execute(&self.pool)
1150        .await?;
1151        Ok(())
1152    }
1153
1154    /// Persist routing head weights (upsert singleton row).
1155    ///
1156    /// # Errors
1157    ///
1158    /// Returns [`MemoryError`] on query failure.
1159    #[cfg(feature = "postgres")]
1160    #[tracing::instrument(skip_all, name = "memory.skills.save_routing_head_weights")]
1161    pub async fn save_routing_head_weights(
1162        &self,
1163        embed_dim: i64,
1164        weights: &[u8],
1165        baseline: f64,
1166        update_count: i64,
1167    ) -> Result<(), MemoryError> {
1168        zeph_db::query(sql!(
1169            "INSERT INTO routing_head_weights (id, embed_dim, weights, baseline, update_count, updated_at) \
1170             VALUES (1, $1, $2, $3, $4, CURRENT_TIMESTAMP) \
1171             ON CONFLICT(id) DO UPDATE SET \
1172               embed_dim = excluded.embed_dim, \
1173               weights = excluded.weights, \
1174               baseline = excluded.baseline, \
1175               update_count = excluded.update_count, \
1176               updated_at = CURRENT_TIMESTAMP"
1177        ))
1178        .bind(embed_dim)
1179        .bind(weights)
1180        .bind(baseline)
1181        .bind(update_count)
1182        .execute(&self.pool)
1183        .await?;
1184        Ok(())
1185    }
1186
1187    // --- AutoSkill A6: Heuristic promotion helpers (spec 061) ---
1188
1189    /// Return skills where the count of heuristics (above `min_confidence`) meets
1190    /// `min_count`. Each entry is `(skill_name, heuristic_count)`.
1191    ///
1192    /// Used by the promotion background task to find qualifying skills.
1193    ///
1194    /// # Errors
1195    ///
1196    /// Returns an error if the query fails.
1197    #[tracing::instrument(skip_all, name = "memory.skills.count_heuristics_by_skill")]
1198    pub async fn count_heuristics_by_skill(
1199        &self,
1200        min_confidence: f64,
1201        min_count: u32,
1202    ) -> Result<Vec<(String, i64)>, MemoryError> {
1203        let rows: Vec<(String, i64)> = zeph_db::query_as(sql!(
1204            "SELECT skill_name, COUNT(*) AS cnt \
1205             FROM skill_heuristics \
1206             WHERE confidence >= ? AND skill_name IS NOT NULL \
1207             GROUP BY skill_name \
1208             HAVING cnt >= ?"
1209        ))
1210        .bind(min_confidence)
1211        .bind(i64::from(min_count))
1212        .fetch_all(&self.pool)
1213        .await?;
1214        Ok(rows)
1215    }
1216
1217    /// Load all heuristic texts for a specific skill above `min_confidence`.
1218    ///
1219    /// Returns heuristic texts sorted alphabetically (deterministic batch hash).
1220    ///
1221    /// # Errors
1222    ///
1223    /// Returns an error if the query fails.
1224    #[tracing::instrument(skip_all, name = "memory.skills.load_heuristic_texts_for_promotion")]
1225    pub async fn load_heuristic_texts_for_promotion(
1226        &self,
1227        skill_name: &str,
1228        min_confidence: f64,
1229    ) -> Result<Vec<String>, MemoryError> {
1230        let rows: Vec<(String,)> = zeph_db::query_as(sql!(
1231            "SELECT heuristic_text \
1232             FROM skill_heuristics \
1233             WHERE skill_name = ? AND confidence >= ? \
1234             ORDER BY heuristic_text ASC"
1235        ))
1236        .bind(skill_name)
1237        .bind(min_confidence)
1238        .fetch_all(&self.pool)
1239        .await?;
1240        Ok(rows.into_iter().map(|r| r.0).collect())
1241    }
1242
1243    /// Check whether `(skill_name, batch_hash)` already exists in `skill_heuristic_promotions`.
1244    ///
1245    /// Returns `true` when the batch was already evaluated — the promotion loop should skip it.
1246    ///
1247    /// # Errors
1248    ///
1249    /// Returns an error if the query fails.
1250    #[tracing::instrument(skip_all, name = "memory.skills.promotion_already_evaluated")]
1251    pub async fn promotion_already_evaluated(
1252        &self,
1253        skill_name: &str,
1254        batch_hash: &str,
1255    ) -> Result<bool, MemoryError> {
1256        let count: i64 = zeph_db::query_scalar(sql!(
1257            "SELECT COUNT(*) FROM skill_heuristic_promotions \
1258             WHERE skill_name = ? AND batch_hash = ?"
1259        ))
1260        .bind(skill_name)
1261        .bind(batch_hash)
1262        .fetch_one(&self.pool)
1263        .await?;
1264        Ok(count > 0)
1265    }
1266
1267    /// Record a promotion evaluation result in `skill_heuristic_promotions`.
1268    ///
1269    /// Uses `INSERT OR IGNORE` so concurrent callers are safe (`batch_hash` is part of
1270    /// the primary key).
1271    ///
1272    /// # Errors
1273    ///
1274    /// Returns an error if the query fails.
1275    #[tracing::instrument(skip_all, name = "memory.skills.record_promotion_evaluation")]
1276    pub async fn record_promotion_evaluation(
1277        &self,
1278        skill_name: &str,
1279        batch_hash: &str,
1280        recommendation: &str,
1281        draft_skill_name: Option<&str>,
1282    ) -> Result<(), MemoryError> {
1283        let now = i64::try_from(
1284            std::time::SystemTime::now()
1285                .duration_since(std::time::UNIX_EPOCH)
1286                .unwrap_or_default()
1287                .as_secs(),
1288        )
1289        .unwrap_or(i64::MAX);
1290        zeph_db::query(sql!(
1291            "INSERT OR IGNORE INTO skill_heuristic_promotions \
1292             (skill_name, batch_hash, evaluated_at, recommendation, draft_skill_name) \
1293             VALUES (?, ?, ?, ?, ?)"
1294        ))
1295        .bind(skill_name)
1296        .bind(batch_hash)
1297        .bind(now)
1298        .bind(recommendation)
1299        .bind(draft_skill_name)
1300        .execute(&self.pool)
1301        .await?;
1302        Ok(())
1303    }
1304}
1305
1306/// Flip the active skill version within an open write transaction: clear the current
1307/// active row for `skill_name`, then mark `version_id` active.
1308///
1309/// Both statements run on the caller's `tx` so the swap is atomic with the caller's
1310/// surrounding work (insert / existence check). Shared by
1311/// [`SqliteStore::save_and_activate_skill_version`], [`SqliteStore::activate_skill_version`],
1312/// and [`SqliteStore::ensure_skill_version_exists`].
1313async fn switch_active_skill_version(
1314    tx: &mut zeph_db::DbTransaction<'_>,
1315    skill_name: &str,
1316    version_id: i64,
1317) -> Result<(), MemoryError> {
1318    zeph_db::query(sql!(
1319        "UPDATE skill_versions SET is_active = FALSE WHERE skill_name = ? AND is_active = TRUE"
1320    ))
1321    .bind(skill_name)
1322    .execute(&mut **tx)
1323    .await?;
1324
1325    zeph_db::query(sql!(
1326        "UPDATE skill_versions SET is_active = TRUE WHERE id = ?"
1327    ))
1328    .bind(version_id)
1329    .execute(&mut **tx)
1330    .await?;
1331    Ok(())
1332}
1333
1334#[cfg(test)]
1335mod tests {
1336    use std::time::Duration;
1337
1338    use tokio::time::sleep;
1339
1340    use super::*;
1341
1342    async fn test_store() -> SqliteStore {
1343        SqliteStore::new(":memory:").await.unwrap()
1344    }
1345
1346    #[tokio::test]
1347    async fn record_skill_usage_increments() {
1348        let store = test_store().await;
1349
1350        store.record_skill_usage(&["git"]).await.unwrap();
1351        store.record_skill_usage(&["git"]).await.unwrap();
1352
1353        let usage = store.load_skill_usage().await.unwrap();
1354        assert_eq!(usage.len(), 1);
1355        assert_eq!(usage[0].skill_name, "git");
1356        assert_eq!(usage[0].invocation_count, 2);
1357    }
1358
1359    #[tokio::test]
1360    async fn load_skill_usage_returns_all() {
1361        let store = test_store().await;
1362
1363        store.record_skill_usage(&["git", "docker"]).await.unwrap();
1364        store.record_skill_usage(&["git"]).await.unwrap();
1365
1366        let usage = store.load_skill_usage().await.unwrap();
1367        assert_eq!(usage.len(), 2);
1368        assert_eq!(usage[0].skill_name, "git");
1369        assert_eq!(usage[0].invocation_count, 2);
1370        assert_eq!(usage[1].skill_name, "docker");
1371        assert_eq!(usage[1].invocation_count, 1);
1372    }
1373
1374    #[tokio::test]
1375    async fn migration_005_creates_tables() {
1376        let store = test_store().await;
1377        let pool = store.pool();
1378
1379        let versions: (i64,) = zeph_db::query_as(sql!(
1380            "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='skill_versions'"
1381        ))
1382        .fetch_one(pool)
1383        .await
1384        .unwrap();
1385        assert_eq!(versions.0, 1);
1386
1387        let outcomes: (i64,) = zeph_db::query_as(sql!(
1388            "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='skill_outcomes'"
1389        ))
1390        .fetch_one(pool)
1391        .await
1392        .unwrap();
1393        assert_eq!(outcomes.0, 1);
1394    }
1395
1396    #[tokio::test]
1397    async fn record_skill_outcome_inserts() {
1398        let store = test_store().await;
1399
1400        store
1401            .record_skill_outcome(
1402                "git",
1403                None,
1404                Some(crate::types::ConversationId(1)),
1405                "success",
1406                None,
1407                None,
1408            )
1409            .await
1410            .unwrap();
1411        store
1412            .record_skill_outcome(
1413                "git",
1414                None,
1415                Some(crate::types::ConversationId(1)),
1416                "tool_failure",
1417                Some("exit code 1"),
1418                None,
1419            )
1420            .await
1421            .unwrap();
1422
1423        let metrics = store.skill_metrics("git").await.unwrap().unwrap();
1424        assert_eq!(metrics.total, 2);
1425        assert_eq!(metrics.successes, 1);
1426        assert_eq!(metrics.failures, 1);
1427    }
1428
1429    #[tokio::test]
1430    async fn skill_metrics_none_for_unknown() {
1431        let store = test_store().await;
1432        let m = store.skill_metrics("nonexistent").await.unwrap();
1433        assert!(m.is_none());
1434    }
1435
1436    #[tokio::test]
1437    async fn load_skill_outcome_stats_grouped() {
1438        let store = test_store().await;
1439
1440        store
1441            .record_skill_outcome("git", None, None, "success", None, None)
1442            .await
1443            .unwrap();
1444        store
1445            .record_skill_outcome("git", None, None, "tool_failure", None, None)
1446            .await
1447            .unwrap();
1448        store
1449            .record_skill_outcome("docker", None, None, "success", None, None)
1450            .await
1451            .unwrap();
1452
1453        let stats = store.load_skill_outcome_stats().await.unwrap();
1454        assert_eq!(stats.len(), 2);
1455        assert_eq!(stats[0].skill_name, "git");
1456        assert_eq!(stats[0].total, 2);
1457        assert_eq!(stats[1].skill_name, "docker");
1458        assert_eq!(stats[1].total, 1);
1459    }
1460
1461    #[tokio::test]
1462    async fn save_and_load_skill_version() {
1463        let store = test_store().await;
1464
1465        let id = store
1466            .save_skill_version("git", 1, "body v1", "Git helper", "manual", None, None)
1467            .await
1468            .unwrap();
1469        assert!(id > 0);
1470
1471        store.activate_skill_version("git", id).await.unwrap();
1472
1473        let active = store.active_skill_version("git").await.unwrap().unwrap();
1474        assert_eq!(active.version, 1);
1475        assert_eq!(active.body, "body v1");
1476        assert!(active.is_active);
1477    }
1478
1479    #[tokio::test]
1480    async fn activate_deactivates_previous() {
1481        let store = test_store().await;
1482
1483        let v1 = store
1484            .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
1485            .await
1486            .unwrap();
1487        store.activate_skill_version("git", v1).await.unwrap();
1488
1489        let v2 = store
1490            .save_skill_version("git", 2, "v2", "desc", "auto", None, Some(v1))
1491            .await
1492            .unwrap();
1493        store.activate_skill_version("git", v2).await.unwrap();
1494
1495        let versions = store.load_skill_versions("git").await.unwrap();
1496        assert_eq!(versions.len(), 2);
1497        assert!(!versions[0].is_active);
1498        assert!(versions[1].is_active);
1499    }
1500
1501    #[tokio::test]
1502    async fn next_skill_version_increments() {
1503        let store = test_store().await;
1504
1505        let next = store.next_skill_version("git").await.unwrap();
1506        assert_eq!(next, 1);
1507
1508        store
1509            .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
1510            .await
1511            .unwrap();
1512        let next = store.next_skill_version("git").await.unwrap();
1513        assert_eq!(next, 2);
1514    }
1515
1516    #[tokio::test]
1517    async fn last_improvement_time_returns_auto_only() {
1518        let store = test_store().await;
1519
1520        store
1521            .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
1522            .await
1523            .unwrap();
1524
1525        let t = store.last_improvement_time("git").await.unwrap();
1526        assert!(t.is_none());
1527
1528        store
1529            .save_skill_version("git", 2, "v2", "desc", "auto", None, None)
1530            .await
1531            .unwrap();
1532
1533        let t = store.last_improvement_time("git").await.unwrap();
1534        assert!(t.is_some());
1535    }
1536
1537    #[tokio::test]
1538    async fn ensure_skill_version_exists_idempotent() {
1539        let store = test_store().await;
1540
1541        store
1542            .ensure_skill_version_exists("git", "body", "Git helper")
1543            .await
1544            .unwrap();
1545        store
1546            .ensure_skill_version_exists("git", "body2", "Git helper 2")
1547            .await
1548            .unwrap();
1549
1550        let versions = store.load_skill_versions("git").await.unwrap();
1551        assert_eq!(versions.len(), 1);
1552        assert_eq!(versions[0].body, "body");
1553    }
1554
1555    #[tokio::test]
1556    async fn load_skill_versions_ordered() {
1557        let store = test_store().await;
1558
1559        let v1 = store
1560            .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
1561            .await
1562            .unwrap();
1563        store
1564            .save_skill_version("git", 2, "v2", "desc", "auto", None, Some(v1))
1565            .await
1566            .unwrap();
1567
1568        let versions = store.load_skill_versions("git").await.unwrap();
1569        assert_eq!(versions.len(), 2);
1570        assert_eq!(versions[0].version, 1);
1571        assert_eq!(versions[1].version, 2);
1572    }
1573
1574    #[tokio::test]
1575    async fn count_auto_versions_only() {
1576        let store = test_store().await;
1577
1578        store
1579            .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
1580            .await
1581            .unwrap();
1582        store
1583            .save_skill_version("git", 2, "v2", "desc", "auto", None, None)
1584            .await
1585            .unwrap();
1586        store
1587            .save_skill_version("git", 3, "v3", "desc", "auto", None, None)
1588            .await
1589            .unwrap();
1590
1591        let count = store.count_auto_versions("git").await.unwrap();
1592        assert_eq!(count, 2);
1593    }
1594
1595    #[tokio::test]
1596    async fn prune_preserves_manual_and_active() {
1597        let store = test_store().await;
1598
1599        let v1 = store
1600            .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
1601            .await
1602            .unwrap();
1603        store.activate_skill_version("git", v1).await.unwrap();
1604
1605        for i in 2..=5 {
1606            store
1607                .save_skill_version("git", i, &format!("v{i}"), "desc", "auto", None, None)
1608                .await
1609                .unwrap();
1610        }
1611
1612        let pruned = store.prune_skill_versions("git", 2).await.unwrap();
1613        assert_eq!(pruned, 2);
1614
1615        let versions = store.load_skill_versions("git").await.unwrap();
1616        assert!(versions.iter().any(|v| v.source == "manual"));
1617        let auto_count = versions.iter().filter(|v| v.source == "auto").count();
1618        assert_eq!(auto_count, 2);
1619    }
1620
1621    #[tokio::test]
1622    async fn predecessor_version_returns_parent() {
1623        let store = test_store().await;
1624
1625        let v1 = store
1626            .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
1627            .await
1628            .unwrap();
1629        let v2 = store
1630            .save_skill_version("git", 2, "v2", "desc", "auto", None, Some(v1))
1631            .await
1632            .unwrap();
1633
1634        let pred = store.predecessor_version(v2).await.unwrap().unwrap();
1635        assert_eq!(pred.id, v1);
1636        assert_eq!(pred.version, 1);
1637    }
1638
1639    #[tokio::test]
1640    async fn predecessor_version_none_for_root() {
1641        let store = test_store().await;
1642
1643        let v1 = store
1644            .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
1645            .await
1646            .unwrap();
1647
1648        let pred = store.predecessor_version(v1).await.unwrap();
1649        assert!(pred.is_none());
1650    }
1651
1652    #[tokio::test]
1653    async fn active_skill_version_none_for_unknown() {
1654        let store = test_store().await;
1655        let active = store.active_skill_version("nonexistent").await.unwrap();
1656        assert!(active.is_none());
1657    }
1658
1659    #[tokio::test]
1660    async fn load_skill_outcome_stats_empty() {
1661        let store = test_store().await;
1662        let stats = store.load_skill_outcome_stats().await.unwrap();
1663        assert!(stats.is_empty());
1664    }
1665
1666    #[tokio::test]
1667    async fn load_skill_versions_empty() {
1668        let store = test_store().await;
1669        let versions = store.load_skill_versions("nonexistent").await.unwrap();
1670        assert!(versions.is_empty());
1671    }
1672
1673    #[tokio::test]
1674    async fn count_auto_versions_zero_for_unknown() {
1675        let store = test_store().await;
1676        let count = store.count_auto_versions("nonexistent").await.unwrap();
1677        assert_eq!(count, 0);
1678    }
1679
1680    #[tokio::test]
1681    async fn prune_nothing_when_below_limit() {
1682        let store = test_store().await;
1683
1684        store
1685            .save_skill_version("git", 1, "v1", "desc", "auto", None, None)
1686            .await
1687            .unwrap();
1688
1689        let pruned = store.prune_skill_versions("git", 5).await.unwrap();
1690        assert_eq!(pruned, 0);
1691    }
1692
1693    #[tokio::test]
1694    async fn record_skill_outcome_with_error_context() {
1695        let store = test_store().await;
1696
1697        store
1698            .record_skill_outcome(
1699                "docker",
1700                None,
1701                Some(crate::types::ConversationId(1)),
1702                "tool_failure",
1703                Some("container not found"),
1704                None,
1705            )
1706            .await
1707            .unwrap();
1708
1709        let metrics = store.skill_metrics("docker").await.unwrap().unwrap();
1710        assert_eq!(metrics.total, 1);
1711        assert_eq!(metrics.failures, 1);
1712    }
1713
1714    #[tokio::test]
1715    async fn save_skill_version_with_error_context() {
1716        let store = test_store().await;
1717
1718        let id = store
1719            .save_skill_version(
1720                "git",
1721                1,
1722                "improved body",
1723                "Git helper",
1724                "auto",
1725                Some("exit code 128"),
1726                None,
1727            )
1728            .await
1729            .unwrap();
1730        assert!(id > 0);
1731    }
1732
1733    #[tokio::test]
1734    async fn record_skill_outcomes_batch_resolves_version_id() {
1735        let store = test_store().await;
1736
1737        let vid = store
1738            .save_skill_version("git", 1, "body", "desc", "manual", None, None)
1739            .await
1740            .unwrap();
1741        store.activate_skill_version("git", vid).await.unwrap();
1742
1743        store
1744            .record_skill_outcomes_batch(
1745                &["git".to_string()],
1746                None,
1747                "tool_failure",
1748                Some("exit code 1"),
1749                Some("exit_nonzero"),
1750            )
1751            .await
1752            .unwrap();
1753
1754        let pool = store.pool();
1755        let row: (Option<i64>, Option<String>) = zeph_db::query_as(sql!(
1756            "SELECT version_id, outcome_detail FROM skill_outcomes WHERE skill_name = 'git' LIMIT 1"
1757        ))
1758        .fetch_one(pool)
1759        .await
1760        .unwrap();
1761        assert_eq!(
1762            row.0,
1763            Some(vid),
1764            "version_id should be resolved to active version"
1765        );
1766        assert_eq!(row.1.as_deref(), Some("exit_nonzero"));
1767    }
1768
1769    #[tokio::test]
1770    async fn record_skill_outcome_stores_outcome_detail() {
1771        let store = test_store().await;
1772
1773        store
1774            .record_skill_outcome("docker", None, None, "tool_failure", None, Some("timeout"))
1775            .await
1776            .unwrap();
1777
1778        let pool = store.pool();
1779        let row: (Option<String>,) = zeph_db::query_as(sql!(
1780            "SELECT outcome_detail FROM skill_outcomes WHERE skill_name = 'docker' LIMIT 1"
1781        ))
1782        .fetch_one(pool)
1783        .await
1784        .unwrap();
1785        assert_eq!(row.0.as_deref(), Some("timeout"));
1786    }
1787
1788    #[tokio::test]
1789    async fn record_skill_outcomes_batch_waits_for_active_writer() {
1790        let file = tempfile::NamedTempFile::new().expect("tempfile");
1791        let path = file.path().to_str().expect("valid path").to_owned();
1792        let store = SqliteStore::with_pool_size(&path, 2)
1793            .await
1794            .expect("with_pool_size");
1795
1796        let vid = store
1797            .save_skill_version("git", 1, "body", "desc", "manual", None, None)
1798            .await
1799            .unwrap();
1800        store.activate_skill_version("git", vid).await.unwrap();
1801
1802        let mut writer_tx = begin_write(store.pool()).await.expect("begin immediate");
1803        zeph_db::query(sql!("INSERT INTO conversations DEFAULT VALUES"))
1804            .execute(&mut *writer_tx)
1805            .await
1806            .expect("hold write lock");
1807
1808        let batch_store = store.clone();
1809        let batch_fut = async move {
1810            batch_store
1811                .record_skill_outcomes_batch(
1812                    &["git".to_string()],
1813                    None,
1814                    "success",
1815                    None,
1816                    Some("waited_for_writer"),
1817                )
1818                .await
1819        };
1820        let batch = tokio::spawn(batch_fut); // EXEMPT: test-only concurrent writer to exercise BEGIN IMMEDIATE serialization
1821
1822        sleep(Duration::from_millis(100)).await;
1823        writer_tx.commit().await.expect("commit writer");
1824
1825        batch
1826            .await
1827            .expect("join batch task")
1828            .expect("record outcomes");
1829
1830        let count: i64 = zeph_db::query_scalar(sql!(
1831            "SELECT COUNT(*) FROM skill_outcomes WHERE skill_name = 'git'"
1832        ))
1833        .fetch_one(store.pool())
1834        .await
1835        .unwrap();
1836        assert_eq!(
1837            count, 1,
1838            "expected batch insert to succeed after writer commits"
1839        );
1840    }
1841
1842    #[tokio::test]
1843    async fn distinct_session_count_empty() {
1844        let store = test_store().await;
1845        let count = store.distinct_session_count("unknown-skill").await.unwrap();
1846        assert_eq!(count, 0);
1847    }
1848
1849    #[tokio::test]
1850    async fn distinct_session_count_single_session() {
1851        let store = test_store().await;
1852        let cid = crate::types::ConversationId(1);
1853        store
1854            .record_skill_outcome("git", None, Some(cid), "success", None, None)
1855            .await
1856            .unwrap();
1857        store
1858            .record_skill_outcome("git", None, Some(cid), "tool_failure", None, None)
1859            .await
1860            .unwrap();
1861        let count = store.distinct_session_count("git").await.unwrap();
1862        assert_eq!(count, 1);
1863    }
1864
1865    #[tokio::test]
1866    async fn distinct_session_count_multiple_sessions() {
1867        let store = test_store().await;
1868        for i in 0..3i64 {
1869            store
1870                .record_skill_outcome(
1871                    "git",
1872                    None,
1873                    Some(crate::types::ConversationId(i)),
1874                    "success",
1875                    None,
1876                    None,
1877                )
1878                .await
1879                .unwrap();
1880        }
1881        let count = store.distinct_session_count("git").await.unwrap();
1882        assert_eq!(count, 3);
1883    }
1884
1885    #[tokio::test]
1886    async fn distinct_session_count_null_conversation_ids_excluded() {
1887        let store = test_store().await;
1888        // Insert outcomes with NULL conversation_id (legacy rows).
1889        store
1890            .record_skill_outcome("git", None, None, "success", None, None)
1891            .await
1892            .unwrap();
1893        store
1894            .record_skill_outcome("git", None, None, "success", None, None)
1895            .await
1896            .unwrap();
1897        let count = store.distinct_session_count("git").await.unwrap();
1898        assert_eq!(count, 0, "NULL conversation_ids must not be counted");
1899    }
1900
1901    // --- STEM: skill_usage_log ---
1902
1903    #[tokio::test]
1904    async fn insert_and_find_recurring_patterns() {
1905        let store = test_store().await;
1906        let seq = r#"["shell","web_scrape"]"#;
1907        let hash = "abcdef0123456789";
1908        let ctx = "ctxhash000000000";
1909
1910        for _ in 0..3 {
1911            store
1912                .insert_tool_usage_log(seq, hash, ctx, "success", None)
1913                .await
1914                .unwrap();
1915        }
1916        store
1917            .insert_tool_usage_log(seq, hash, ctx, "failure", None)
1918            .await
1919            .unwrap();
1920
1921        let patterns = store.find_recurring_patterns(3, 90).await.unwrap();
1922        assert_eq!(patterns.len(), 1);
1923        let (s, h, occ, suc) = &patterns[0];
1924        assert_eq!(s, seq);
1925        assert_eq!(h, hash);
1926        assert_eq!(*occ, 4);
1927        assert_eq!(*suc, 3);
1928    }
1929
1930    #[tokio::test]
1931    async fn find_recurring_patterns_below_threshold_returns_empty() {
1932        let store = test_store().await;
1933        let seq = r#"["shell"]"#;
1934        let hash = "0000000000000001";
1935        let ctx = "0000000000000001";
1936
1937        store
1938            .insert_tool_usage_log(seq, hash, ctx, "success", None)
1939            .await
1940            .unwrap();
1941
1942        let patterns = store.find_recurring_patterns(3, 90).await.unwrap();
1943        assert!(patterns.is_empty());
1944    }
1945
1946    #[tokio::test]
1947    async fn prune_tool_usage_log_removes_old_rows() {
1948        let store = test_store().await;
1949        // Insert a row with an artificially old timestamp so it falls within 0-day window.
1950        zeph_db::query(sql!(
1951            "INSERT INTO skill_usage_log \
1952             (tool_sequence, sequence_hash, context_hash, outcome, created_at) \
1953             VALUES (?, ?, ?, ?, datetime('now', '-2 days'))"
1954        ))
1955        .bind(r#"["shell"]"#)
1956        .bind("hash0000000000001")
1957        .bind("ctx00000000000001")
1958        .bind("success")
1959        .execute(store.pool())
1960        .await
1961        .unwrap();
1962
1963        // Prune rows older than 1 day — the row above is 2 days old so it must be removed.
1964        let removed = store.prune_tool_usage_log(1).await.unwrap();
1965        assert_eq!(removed, 1);
1966    }
1967
1968    // --- ERL: skill_heuristics ---
1969
1970    #[tokio::test]
1971    async fn insert_and_load_skill_heuristics() {
1972        let store = test_store().await;
1973
1974        let id = store
1975            .insert_skill_heuristic(Some("git"), "always commit in small chunks", 0.9)
1976            .await
1977            .unwrap();
1978        assert!(id > 0);
1979
1980        let rows = store.load_skill_heuristics("git", 0.5, 10).await.unwrap();
1981        assert_eq!(rows.len(), 1);
1982        assert_eq!(rows[0].1, "always commit in small chunks");
1983        assert!((rows[0].2 - 0.9).abs() < 1e-6);
1984    }
1985
1986    #[tokio::test]
1987    async fn load_skill_heuristics_includes_general() {
1988        let store = test_store().await;
1989
1990        store
1991            .insert_skill_heuristic(None, "general tip", 0.7)
1992            .await
1993            .unwrap();
1994        store
1995            .insert_skill_heuristic(Some("git"), "git tip", 0.8)
1996            .await
1997            .unwrap();
1998
1999        // querying for "git" should include both the git-specific and the general (NULL) heuristic
2000        let rows = store.load_skill_heuristics("git", 0.5, 10).await.unwrap();
2001        assert_eq!(rows.len(), 2);
2002    }
2003
2004    #[tokio::test]
2005    async fn load_skill_heuristics_filters_by_min_confidence() {
2006        let store = test_store().await;
2007
2008        store
2009            .insert_skill_heuristic(Some("git"), "low confidence tip", 0.3)
2010            .await
2011            .unwrap();
2012        store
2013            .insert_skill_heuristic(Some("git"), "high confidence tip", 0.9)
2014            .await
2015            .unwrap();
2016
2017        let rows = store.load_skill_heuristics("git", 0.5, 10).await.unwrap();
2018        assert_eq!(rows.len(), 1);
2019        assert_eq!(rows[0].1, "high confidence tip");
2020    }
2021
2022    #[tokio::test]
2023    async fn increment_heuristic_use_count_works() {
2024        let store = test_store().await;
2025
2026        let id = store
2027            .insert_skill_heuristic(Some("git"), "tip", 0.8)
2028            .await
2029            .unwrap();
2030
2031        store.increment_heuristic_use_count(id).await.unwrap();
2032        store.increment_heuristic_use_count(id).await.unwrap();
2033
2034        let rows = store.load_skill_heuristics("git", 0.0, 10).await.unwrap();
2035        assert_eq!(rows[0].3, 2); // use_count
2036    }
2037
2038    #[tokio::test]
2039    async fn load_all_heuristics_for_skill_exact_match() {
2040        let store = test_store().await;
2041
2042        store
2043            .insert_skill_heuristic(Some("git"), "git tip", 0.8)
2044            .await
2045            .unwrap();
2046        store
2047            .insert_skill_heuristic(Some("docker"), "docker tip", 0.8)
2048            .await
2049            .unwrap();
2050
2051        let rows = store
2052            .load_all_heuristics_for_skill(Some("git"))
2053            .await
2054            .unwrap();
2055        assert_eq!(rows.len(), 1);
2056        assert_eq!(rows[0].1, "git tip");
2057    }
2058
2059    #[tokio::test]
2060    async fn load_all_heuristics_for_skill_null() {
2061        let store = test_store().await;
2062
2063        store
2064            .insert_skill_heuristic(None, "general", 0.8)
2065            .await
2066            .unwrap();
2067        store
2068            .insert_skill_heuristic(Some("git"), "git tip", 0.8)
2069            .await
2070            .unwrap();
2071
2072        let rows = store.load_all_heuristics_for_skill(None).await.unwrap();
2073        assert_eq!(rows.len(), 1);
2074        assert_eq!(rows[0].1, "general");
2075    }
2076
2077    #[tokio::test]
2078    async fn skill_trust_default_is_quarantined() {
2079        // Verify the DB schema default for skill_trust.trust_level is 'quarantined'.
2080        // ARISE-generated versions do not call set_skill_trust_level, so they inherit
2081        // this default when the trust row is first created by the scanner.
2082        let store = test_store().await;
2083
2084        // Insert a trust row without specifying trust_level to exercise the DEFAULT.
2085        zeph_db::query(sql!(
2086            "INSERT INTO skill_trust (skill_name, blake3_hash) VALUES ('test-arise', 'abc')"
2087        ))
2088        .execute(store.pool())
2089        .await
2090        .unwrap();
2091
2092        let trust: (String,) = zeph_db::query_as(sql!(
2093            "SELECT trust_level FROM skill_trust WHERE skill_name = 'test-arise'"
2094        ))
2095        .fetch_one(store.pool())
2096        .await
2097        .unwrap();
2098
2099        assert_eq!(
2100            trust.0, "quarantined",
2101            "schema default for skill_trust.trust_level must be 'quarantined'"
2102        );
2103    }
2104
2105    #[tokio::test]
2106    async fn save_and_activate_skill_version_activates_new() {
2107        let store = test_store().await;
2108
2109        let id = store
2110            .save_and_activate_skill_version(
2111                "git",
2112                1,
2113                "body v1",
2114                "Git helper",
2115                "manual",
2116                None,
2117                None,
2118            )
2119            .await
2120            .unwrap();
2121        assert!(id > 0);
2122
2123        let active = store.active_skill_version("git").await.unwrap().unwrap();
2124        assert_eq!(active.id, id);
2125        assert_eq!(active.version, 1);
2126        assert_eq!(active.body, "body v1");
2127        assert!(active.is_active);
2128    }
2129
2130    #[tokio::test]
2131    async fn save_and_activate_skill_version_deactivates_previous() {
2132        let store = test_store().await;
2133
2134        let v1 = store
2135            .save_and_activate_skill_version("git", 1, "v1", "desc", "manual", None, None)
2136            .await
2137            .unwrap();
2138
2139        let v2 = store
2140            .save_and_activate_skill_version("git", 2, "v2", "desc", "auto", None, Some(v1))
2141            .await
2142            .unwrap();
2143
2144        let versions = store.load_skill_versions("git").await.unwrap();
2145        assert_eq!(versions.len(), 2);
2146
2147        let old = versions.iter().find(|v| v.id == v1).unwrap();
2148        let new = versions.iter().find(|v| v.id == v2).unwrap();
2149        assert!(!old.is_active, "previous version must be deactivated");
2150        assert!(new.is_active, "new version must be active");
2151    }
2152
2153    #[tokio::test]
2154    async fn save_and_activate_skill_version_only_one_active() {
2155        let store = test_store().await;
2156
2157        let mut last_id = 0i64;
2158        for i in 1i64..=4 {
2159            last_id = store
2160                .save_and_activate_skill_version(
2161                    "git",
2162                    i,
2163                    &format!("v{i}"),
2164                    "desc",
2165                    "auto",
2166                    None,
2167                    None,
2168                )
2169                .await
2170                .unwrap();
2171        }
2172
2173        let versions = store.load_skill_versions("git").await.unwrap();
2174        let active_count = versions.iter().filter(|v| v.is_active).count();
2175        assert_eq!(active_count, 1, "exactly one version must be active");
2176        assert_eq!(
2177            versions.iter().find(|v| v.is_active).unwrap().id,
2178            last_id,
2179            "the last saved version must be the active one"
2180        );
2181    }
2182
2183    // ── AutoSkill A6: heuristic promotion helpers ─────────────────────────
2184
2185    async fn insert_heuristic(store: &SqliteStore, skill: &str, text: &str, confidence: f64) {
2186        zeph_db::query(sql!(
2187            "INSERT INTO skill_heuristics (skill_name, heuristic_text, confidence) VALUES (?, ?, ?)"
2188        ))
2189        .bind(skill)
2190        .bind(text)
2191        .bind(confidence)
2192        .execute(store.pool())
2193        .await
2194        .unwrap();
2195    }
2196
2197    /// Regression test for #4531: `NULL` `skill_name` heuristics must be excluded from
2198    /// `count_heuristics_by_skill` so sqlx does not encounter a non-null column mismatch.
2199    #[tokio::test]
2200    async fn count_heuristics_by_skill_excludes_null_skill_name() {
2201        let store = test_store().await;
2202
2203        // Insert heuristics with NULL skill_name (general/unattributed).
2204        store
2205            .insert_skill_heuristic(None, "general tip 1", 0.9)
2206            .await
2207            .unwrap();
2208        store
2209            .insert_skill_heuristic(None, "general tip 2", 0.8)
2210            .await
2211            .unwrap();
2212        store
2213            .insert_skill_heuristic(None, "general tip 3", 0.7)
2214            .await
2215            .unwrap();
2216
2217        // NULL rows alone must not produce any results (no skill_name to group by).
2218        let results = store.count_heuristics_by_skill(0.5, 2).await.unwrap();
2219        assert!(
2220            results.is_empty(),
2221            "NULL skill_name heuristics must be excluded from count_heuristics_by_skill results"
2222        );
2223
2224        // Verify that named skills are still counted correctly alongside NULL rows.
2225        insert_heuristic(&store, "git", "use --no-pager", 0.8).await;
2226        insert_heuristic(&store, "git", "check status first", 0.9).await;
2227
2228        let results = store.count_heuristics_by_skill(0.5, 2).await.unwrap();
2229        assert_eq!(results.len(), 1, "only git should qualify");
2230        assert_eq!(results[0].0, "git");
2231        assert_eq!(results[0].1, 2, "git has exactly 2 qualifying heuristics");
2232    }
2233
2234    #[tokio::test]
2235    async fn count_heuristics_by_skill_threshold_and_confidence() {
2236        let store = test_store().await;
2237
2238        // "git" has 3 heuristics: 2 above min_confidence, 1 below
2239        insert_heuristic(&store, "git", "use --no-pager", 0.8).await;
2240        insert_heuristic(&store, "git", "check status first", 0.9).await;
2241        insert_heuristic(&store, "git", "low confidence hint", 0.3).await;
2242
2243        // "docker" has 1 heuristic above confidence — below min_count=2
2244        insert_heuristic(&store, "docker", "use --rm", 0.7).await;
2245
2246        let results = store.count_heuristics_by_skill(0.5, 2).await.unwrap();
2247        assert_eq!(results.len(), 1, "only git meets threshold");
2248        assert_eq!(results[0].0, "git");
2249        assert_eq!(results[0].1, 2, "only heuristics >= 0.5 confidence counted");
2250    }
2251
2252    #[tokio::test]
2253    async fn load_heuristic_texts_sorted_for_deterministic_hash() {
2254        let store = test_store().await;
2255
2256        insert_heuristic(&store, "git", "zebra", 0.8).await;
2257        insert_heuristic(&store, "git", "alpha", 0.9).await;
2258        insert_heuristic(&store, "git", "middle", 0.7).await;
2259        insert_heuristic(&store, "git", "skipped", 0.2).await; // below min_confidence
2260
2261        let texts = store
2262            .load_heuristic_texts_for_promotion("git", 0.5)
2263            .await
2264            .unwrap();
2265
2266        assert_eq!(texts.len(), 3);
2267        assert_eq!(
2268            texts,
2269            vec!["alpha", "middle", "zebra"],
2270            "must be sorted ASC"
2271        );
2272    }
2273
2274    #[tokio::test]
2275    async fn promotion_already_evaluated_idempotency() {
2276        let store = test_store().await;
2277
2278        // Not yet evaluated.
2279        let found = store
2280            .promotion_already_evaluated("git", "abc123")
2281            .await
2282            .unwrap();
2283        assert!(!found);
2284
2285        store
2286            .record_promotion_evaluation("git", "abc123", "none", None)
2287            .await
2288            .unwrap();
2289
2290        // Now it should be found.
2291        let found = store
2292            .promotion_already_evaluated("git", "abc123")
2293            .await
2294            .unwrap();
2295        assert!(found);
2296    }
2297
2298    #[tokio::test]
2299    async fn record_promotion_evaluation_insert_or_ignore() {
2300        let store = test_store().await;
2301
2302        // First insert — succeeds.
2303        store
2304            .record_promotion_evaluation("git", "hash1", "body_enrichment", Some("git-v2"))
2305            .await
2306            .unwrap();
2307
2308        // Duplicate insert — should not error (INSERT OR IGNORE).
2309        store
2310            .record_promotion_evaluation("git", "hash1", "new_skill", Some("git-extra"))
2311            .await
2312            .unwrap();
2313
2314        // Only one row should exist; recommendation is from the first insert.
2315        let rec: (String,) = zeph_db::query_as(sql!(
2316            "SELECT recommendation FROM skill_heuristic_promotions WHERE skill_name = ? AND batch_hash = ?"
2317        ))
2318        .bind("git")
2319        .bind("hash1")
2320        .fetch_one(store.pool())
2321        .await
2322        .unwrap();
2323        assert_eq!(
2324            rec.0, "body_enrichment",
2325            "first insert wins (INSERT OR IGNORE)"
2326        );
2327    }
2328}