diff --git a/_primitives/_rust/kei-chat-store/src/schema.rs b/_primitives/_rust/kei-chat-store/src/schema.rs index 2291c94..56d58d7 100644 --- a/_primitives/_rust/kei-chat-store/src/schema.rs +++ b/_primitives/_rust/kei-chat-store/src/schema.rs @@ -1,22 +1,26 @@ //! kei-chat-store EntitySchema — declarative spec consumed by //! `kei_entity_store::Store` and its verb templates. //! -//! Shape (Layer-A convergence, 2026-04-23): +//! Shape (Layer-A convergence, 2026-04-23; cost-column re-migration +//! 2026-04-23 wave 8): //! //! - Primary entity = `chat_messages` (INTEGER PK; required by engine). //! Engine owns: create / get / list / search verbs + FTS reindex. -//! - Bespoke: `chat_sessions` has a TEXT UUID primary key that the -//! engine's `FieldKind::IntegerPk` cannot represent, so its DDL rides -//! the engine's `custom_migrations` slot and its CRUD stays in -//! `sessions.rs` (analogous to kei-task's milestones / deps / graph). -//! - Archive: sessions use a TEXT `status` enum ('active' | 'archived') -//! rather than an INTEGER flag, so the engine `archive` verb is NOT -//! enabled. Session archival stays bespoke. -//! - Per-message `cost` (REAL in legacy) is dropped from `chat_messages`: -//! the engine has no REAL FieldKind and the only consumer is the -//! session-level aggregate `chat_sessions.total_cost`, updated -//! bespoke in `save_message`. No caller reads per-message cost in -//! current tests or the CLI surface. +//! - Bespoke: `chat_sessions` has a TEXT UUID primary key. The engine +//! gained `FieldKind::TextPk` in wave 8 but Store::open currently +//! takes a SINGLE EntitySchema, so a second managed schema for +//! sessions would require engine multi-schema support. Until that +//! lands, the session DDL rides `custom_migrations` and its CRUD +//! stays in `sessions.rs` (analogous to kei-task's milestones / deps +//! / graph). **Known open:** promote chat_sessions to a second +//! EntitySchema (`TextPk` + `TextArchiveEnum`) once engine gains +//! multi-schema support. +//! - Archive: sessions use a TEXT `status` enum ('active' | 'archived'). +//! Session archival stays bespoke (same reason as above). +//! - Per-message `cost` (REAL) is restored via +//! `FieldKind::RealDefault(0.0)` — wave-8 addition. Engine-managed +//! INSERT/SELECT, no bespoke SQL needed. Previously dropped when +//! the engine had no REAL kind; re-instated 2026-04-23. //! //! FTS column-name change vs pre-convergence shape: //! legacy fts_chat(message_id, session_id UNINDEXED, content) @@ -34,6 +38,7 @@ static FIELDS: &[FieldDef] = &[ FieldDef::text_nn("content"), FieldDef::integer("tokens_in"), FieldDef::integer("tokens_out"), + FieldDef::real_default("cost", 0.0), FieldDef::created_at(), ]; diff --git a/_primitives/_rust/kei-chat-store/src/search.rs b/_primitives/_rust/kei-chat-store/src/search.rs index 591ef11..198b7e8 100644 --- a/_primitives/_rust/kei-chat-store/src/search.rs +++ b/_primitives/_rust/kei-chat-store/src/search.rs @@ -4,8 +4,8 @@ //! `kei_entity_store::verbs::search` using `CHAT_SCHEMA`. The engine //! handles FTS5 JOIN + rank ordering; this module maps the generic //! JSON result back to typed `ChatMessage` rows for legacy callers. -//! Per-message `cost` is not persisted after the convergence (see -//! `schema.rs` note); `cost` is populated as 0.0 on every hit. +//! Per-message `cost` is persisted (engine `RealDefault` field); +//! `row_to_message` reads it back as f64. use crate::schema::CHAT_SCHEMA; use crate::sessions::ChatMessage; @@ -32,7 +32,7 @@ fn row_to_message(r: &Value) -> Result { content: r["content"].as_str().unwrap_or("").into(), tokens_in: r["tokens_in"].as_i64().unwrap_or(0), tokens_out: r["tokens_out"].as_i64().unwrap_or(0), - cost: 0.0, + cost: r["cost"].as_f64().unwrap_or(0.0), created_at: r["created_at"].as_i64().unwrap_or(0), }) } diff --git a/_primitives/_rust/kei-chat-store/src/sessions.rs b/_primitives/_rust/kei-chat-store/src/sessions.rs index a2e4b96..39cd56e 100644 --- a/_primitives/_rust/kei-chat-store/src/sessions.rs +++ b/_primitives/_rust/kei-chat-store/src/sessions.rs @@ -61,6 +61,7 @@ pub fn save_message(store: &Store, msg: &ChatMessage) -> Result { "content": msg.content, "tokens_in": msg.tokens_in, "tokens_out": msg.tokens_out, + "cost": msg.cost, "created_at": msg.created_at, }); let v = v_create::run(store.conn(), &CHAT_SCHEMA, payload) diff --git a/_primitives/_rust/kei-chat-store/tests/integration.rs b/_primitives/_rust/kei-chat-store/tests/integration.rs index 9385315..0d424e3 100644 --- a/_primitives/_rust/kei-chat-store/tests/integration.rs +++ b/_primitives/_rust/kei-chat-store/tests/integration.rs @@ -62,6 +62,26 @@ fn engine_migration_parity_smoke() { assert_eq!(sess.message_count, 1); } +#[test] +fn cost_roundtrips_via_search() { + // Wave-8 re-migration: cost is re-instated as engine-managed + // RealDefault column. The value written via save_message must be + // visible on the ChatMessage returned from search (no longer 0.0). + let s = mk(); + let sid = start_session(&s, "demo", "", "").unwrap(); + save_message(&s, &ChatMessage { + session_id: sid, role: "user".into(), + content: "rust async tokio bench cost-marker".into(), + tokens_in: 1, tokens_out: 1, cost: 0.00777, + ..Default::default() + }).unwrap(); + let hits = search(&s, "cost-marker", 10).unwrap(); + assert_eq!(hits.len(), 1); + assert!((hits[0].cost - 0.00777).abs() < 1e-9, + "cost should round-trip via engine RealDefault column, got {}", + hits[0].cost); +} + #[test] fn stats_aggregates() { let s = mk();