Skip to main content

khive_runtime/
presentation.rs

1//! Verb response presentation modes and transformation (ADR-045).
2//!
3//! Handlers always return a canonical (verbose) shape. This module transforms
4//! that shape into a caller-appropriate form AFTER dispatch, BEFORE wire
5//! serialization.
6//!
7//! ## Transformation rules
8//!
9//! | Field type          | Verbose form                  | Agent form            |
10//! | ------------------- | ----------------------------- | --------------------- |
11//! | UUID (36-char)      | `"a1b2c3d4-e5f6-..."`         | `"a1b2c3d4"` (8 chars)|
12//! | ISO-8601 timestamp  | `"2026-05-23T16:18:15.234Z"`  | `"2026-05-23T16:18"` (< 24h: `"3m ago"`) |
13//! | Empty string `""`   | included                      | dropped               |
14//! | Empty array `[]`    | included                      | dropped               |
15//! | Empty object `{}`   | included                      | dropped               |
16//! | `null` (non-lifecycle) | included                   | dropped               |
17//! | `null` (lifecycle `*_at`, relationship markers) | included | preserved |
18//! | Score fields        | `0.1234567890`                | `0.123` (3 sig figs)  |
19//!
20//! `Verbose` mode passes through canonically. `Human` mode is delegated to the
21//! CLI layer and is not transformed here (returned as-is from this crate).
22//!
23//! **Chain invariant:** `present_response` MUST NOT be called on intermediate
24//! chain results — only on the final response envelope after all `$prev`
25//! substitutions complete.
26
27use std::collections::HashSet;
28
29use serde::{Deserialize, Serialize};
30use serde_json::{Map, Value};
31
32/// How the response envelope is presented to the caller (ADR-045).
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
34#[serde(rename_all = "snake_case")]
35pub enum PresentationMode {
36    /// Token-efficient. Default for MCP callers (agents).
37    ///
38    /// Short UUIDs (8-char), compact timestamps (minute granularity or
39    /// relative), empty fields dropped, lifecycle nulls preserved, score
40    /// fields truncated to 3 significant figures.
41    #[default]
42    Agent,
43    /// Full canonical shape. Default for `kkernel call` and CI/scripted callers.
44    ///
45    /// No transformation — handler output passes through as-is.
46    Verbose,
47    /// Pretty-printed terminal output. Default for `khive` CLI.
48    ///
49    /// Formatting is delegated to the CLI layer; this crate returns the value
50    /// unchanged (same as Verbose at the runtime level).
51    Human,
52}
53
54/// Lifecycle `null` fields that are PRESERVED in Agent mode even when null.
55///
56/// These fields carry lifecycle meaning (absent ≠ null) and must not be dropped.
57/// ADR-045 §3 Agent mode — "Drop semantics — lifecycle null preservation".
58const LIFECYCLE_NULL_PRESERVE: &[&str] = &[
59    "completed_at",
60    "deleted_at",
61    "due_at",
62    "read_at",
63    "started_at",
64    "superseded_at",
65    "applied_at",
66    "withdrawn_at",
67    "reviewed_at",
68    "parent_id",
69    "superseded_by",
70    "replaced_by",
71];
72
73/// Score field names that are truncated to 3 significant figures in Agent mode.
74///
75/// ADR-045 §3 Agent mode — "Score truncation".
76const SCORE_FIELDS: &[&str] = &[
77    "score",
78    "salience",
79    "decay_factor",
80    "rrf_score",
81    "similarity",
82    "cross_encoder_score",
83    "graph_proximity_score",
84];
85
86/// UUID v4 canonical string length (8-4-4-4-12 = 32 hex + 4 dashes = 36).
87const UUID_CANONICAL_LEN: usize = 36;
88
89/// Transform a successful verb result value according to the given
90/// [`PresentationMode`].
91///
92/// - `Verbose` / `Human`: returns `value` unchanged.
93/// - `Agent`: applies UUID shortening, timestamp compaction, empty-field
94///   dropping, lifecycle-null preservation, and score truncation.
95///
96/// `now_unix_seconds` is sampled once per response and passed through so all
97/// relative datetime renderings within a response use the same instant.
98pub fn present(value: Value, mode: PresentationMode, now_unix_seconds: i64) -> Value {
99    match mode {
100        PresentationMode::Verbose | PresentationMode::Human => value,
101        PresentationMode::Agent => {
102            let lifecycle_preserve: HashSet<&str> =
103                LIFECYCLE_NULL_PRESERVE.iter().copied().collect();
104            let score_fields: HashSet<&str> = SCORE_FIELDS.iter().copied().collect();
105            transform_agent(value, &lifecycle_preserve, &score_fields, now_unix_seconds)
106        }
107    }
108}
109
110/// Apply the Agent-mode transform to an arbitrary JSON value.
111fn transform_agent(
112    value: Value,
113    lifecycle: &HashSet<&str>,
114    scores: &HashSet<&str>,
115    now: i64,
116) -> Value {
117    match value {
118        Value::Object(map) => {
119            let mut out = Map::new();
120            for (k, v) in map {
121                let transformed = transform_field_agent(&k, v, lifecycle, scores, now);
122                match transformed {
123                    None => {} // drop
124                    Some(tv) => {
125                        out.insert(k, tv);
126                    }
127                }
128            }
129            Value::Object(out)
130        }
131        Value::Array(arr) => {
132            let items: Vec<Value> = arr
133                .into_iter()
134                .map(|v| transform_agent(v, lifecycle, scores, now))
135                .collect();
136            Value::Array(items)
137        }
138        other => other,
139    }
140}
141
142/// Transform a single named field value under Agent mode.
143///
144/// Returns `None` if the field should be dropped.
145fn transform_field_agent(
146    key: &str,
147    value: Value,
148    lifecycle: &HashSet<&str>,
149    scores: &HashSet<&str>,
150    now: i64,
151) -> Option<Value> {
152    match &value {
153        // Preserve lifecycle nulls; drop other nulls.
154        Value::Null => {
155            if lifecycle.contains(key) {
156                Some(value)
157            } else {
158                None
159            }
160        }
161        // Drop empty strings, arrays, objects.
162        Value::String(s) if s.is_empty() => None,
163        Value::Array(a) if a.is_empty() => None,
164        Value::Object(o) if o.is_empty() => None,
165        // Truncate score fields.
166        Value::Number(_) if scores.contains(key) => {
167            if let Some(f) = value.as_f64() {
168                Some(truncate_to_3_sig_figs(f))
169            } else {
170                Some(value)
171            }
172        }
173        // Shorten UUIDs in string fields.
174        Value::String(s) if is_canonical_uuid(s) => Some(Value::String(s[..8].to_string())),
175        // Compact ISO-8601 timestamps in string fields.
176        Value::String(s) if looks_like_iso8601(s) => Some(Value::String(compact_timestamp(s, now))),
177        // Recurse into objects and arrays.
178        Value::Object(_) | Value::Array(_) => Some(transform_agent(value, lifecycle, scores, now)),
179        // Everything else passes through.
180        _ => Some(value),
181    }
182}
183
184/// Returns `true` if `s` looks like a canonical UUID (36 chars, standard form).
185fn is_canonical_uuid(s: &str) -> bool {
186    if s.len() != UUID_CANONICAL_LEN {
187        return false;
188    }
189    let b = s.as_bytes();
190    // Pattern: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
191    b[8] == b'-'
192        && b[13] == b'-'
193        && b[18] == b'-'
194        && b[23] == b'-'
195        && b[..8].iter().all(|c| c.is_ascii_hexdigit())
196        && b[9..13].iter().all(|c| c.is_ascii_hexdigit())
197        && b[14..18].iter().all(|c| c.is_ascii_hexdigit())
198        && b[19..23].iter().all(|c| c.is_ascii_hexdigit())
199        && b[24..].iter().all(|c| c.is_ascii_hexdigit())
200}
201
202/// Returns `true` if `s` looks like an ISO-8601 datetime string.
203///
204/// Heuristic: starts with `YYYY-MM-DDTHH:` (16 chars, proper digit positions).
205fn looks_like_iso8601(s: &str) -> bool {
206    if s.len() < 16 {
207        return false;
208    }
209    let b = s.as_bytes();
210    b[4] == b'-'
211        && b[7] == b'-'
212        && b[10] == b'T'
213        && b[13] == b':'
214        && b[..4].iter().all(|c| c.is_ascii_digit())
215        && b[5..7].iter().all(|c| c.is_ascii_digit())
216        && b[8..10].iter().all(|c| c.is_ascii_digit())
217        && b[11..13].iter().all(|c| c.is_ascii_digit())
218}
219
220/// Compact an ISO-8601 timestamp for Agent mode.
221///
222/// - Within the last 24 hours: relative form (e.g. `"3m ago"`, `"2h ago"`).
223/// - Older: minute-granularity absolute form `"YYYY-MM-DDTHH:MM"`.
224fn compact_timestamp(s: &str, now: i64) -> String {
225    // Parse Unix seconds from the timestamp if possible; fall back to truncation.
226    if let Some(unix) = parse_iso8601_unix(s) {
227        let diff = now - unix;
228        if (0..86400).contains(&diff) {
229            return relative_time(diff);
230        }
231    }
232    // Minute granularity: take the first 16 chars.
233    s.chars().take(16).collect()
234}
235
236/// Attempt to parse an ISO-8601 datetime string to Unix seconds.
237///
238/// Only handles the subset produced by khive handlers:
239/// `YYYY-MM-DDTHH:MM:SS[.frac][Z]`. Returns `None` for anything we can't parse
240/// (graceful degradation — the timestamp is still compacted by truncation).
241fn parse_iso8601_unix(s: &str) -> Option<i64> {
242    // Minimum parseable: "YYYY-MM-DDTHH:MM:SS"
243    if s.len() < 19 {
244        return None;
245    }
246    let b = s.as_bytes();
247    let year: i64 = parse_digits(&b[0..4])?;
248    let month: i64 = parse_digits(&b[5..7])?;
249    let day: i64 = parse_digits(&b[8..10])?;
250    let hour: i64 = parse_digits(&b[11..13])?;
251    let minute: i64 = parse_digits(&b[14..16])?;
252    let second: i64 = parse_digits(&b[17..19])?;
253
254    // Simple Gregorian → Unix seconds (no timezone offsets other than 'Z').
255    // Close enough for relative-time comparisons; not for calendar correctness.
256    let days_since_epoch = days_from_civil(year, month, day);
257    Some(days_since_epoch * 86400 + hour * 3600 + minute * 60 + second)
258}
259
260fn parse_digits(b: &[u8]) -> Option<i64> {
261    let s = std::str::from_utf8(b).ok()?;
262    s.parse().ok()
263}
264
265/// Gregorian date → days since 1970-01-01. Algorithm: Howard Hinnant's civil.
266fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
267    let y = if m <= 2 { y - 1 } else { y };
268    let era = y.div_euclid(400);
269    let yoe = y - era * 400;
270    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
271    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
272    era * 146097 + doe - 719468
273}
274
275/// Format a duration in seconds as a relative time string (e.g. `"3m ago"`).
276fn relative_time(diff_secs: i64) -> String {
277    if diff_secs < 60 {
278        format!("{diff_secs}s ago")
279    } else if diff_secs < 3600 {
280        format!("{}m ago", diff_secs / 60)
281    } else {
282        format!("{}h ago", diff_secs / 3600)
283    }
284}
285
286/// Truncate a float to 3 significant figures, returning a `serde_json::Value`.
287fn truncate_to_3_sig_figs(f: f64) -> Value {
288    if f == 0.0 || !f.is_finite() {
289        return Value::from(f);
290    }
291    let magnitude = f.abs().log10().floor() as i32;
292    let factor = 10f64.powi(2 - magnitude);
293    let rounded = (f * factor).round() / factor;
294    // Re-serialize through serde_json to avoid floating-point noise.
295    serde_json::Number::from_f64(rounded)
296        .map(Value::Number)
297        .unwrap_or(Value::from(rounded))
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use serde_json::json;
304
305    /// A fixed "now" for deterministic tests: 2026-05-23T16:18:00Z ≈ 1748016480.
306    const NOW: i64 = 1_748_016_480;
307
308    fn agent(v: Value) -> Value {
309        present(v, PresentationMode::Agent, NOW)
310    }
311
312    #[test]
313    fn verbose_passthrough() {
314        let v = json!({"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "title": "X"});
315        let out = present(v.clone(), PresentationMode::Verbose, NOW);
316        assert_eq!(out, v);
317    }
318
319    #[test]
320    fn agent_shortens_uuid() {
321        let v = json!({"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"});
322        let out = agent(v);
323        assert_eq!(out["id"], json!("a1b2c3d4"));
324    }
325
326    #[test]
327    fn agent_drops_empty_string() {
328        let v = json!({"title": "ok", "description": ""});
329        let out = agent(v);
330        assert!(out.get("description").is_none());
331        assert_eq!(out["title"], json!("ok"));
332    }
333
334    #[test]
335    fn agent_drops_empty_array() {
336        let v = json!({"tags": [], "title": "ok"});
337        let out = agent(v);
338        assert!(out.get("tags").is_none());
339    }
340
341    #[test]
342    fn agent_drops_empty_object() {
343        let v = json!({"properties": {}, "title": "ok"});
344        let out = agent(v);
345        assert!(out.get("properties").is_none());
346    }
347
348    #[test]
349    fn agent_drops_non_lifecycle_null() {
350        let v = json!({"result": null, "title": "ok"});
351        let out = agent(v);
352        assert!(out.get("result").is_none());
353    }
354
355    #[test]
356    fn agent_preserves_lifecycle_null() {
357        let v = json!({"completed_at": null, "due_at": null, "title": "ok"});
358        let out = agent(v);
359        assert_eq!(out["completed_at"], json!(null));
360        assert_eq!(out["due_at"], json!(null));
361    }
362
363    #[test]
364    fn agent_preserves_relationship_null() {
365        let v = json!({"parent_id": null, "superseded_by": null});
366        let out = agent(v);
367        assert_eq!(out["parent_id"], json!(null));
368        assert_eq!(out["superseded_by"], json!(null));
369    }
370
371    #[test]
372    fn agent_truncates_score_field() {
373        let v = json!({"score": 0.12345678});
374        let out = agent(v);
375        let s = out["score"].as_f64().unwrap();
376        assert!((s - 0.123).abs() < 1e-9, "expected ~0.123, got {s}");
377    }
378
379    #[test]
380    fn agent_compacts_old_timestamp_to_minutes() {
381        // Far past — not within 24h of NOW. Should be truncated to 16 chars.
382        let v = json!({"created_at": "2020-01-01T10:30:45.123456Z"});
383        let out = agent(v);
384        assert_eq!(out["created_at"], json!("2020-01-01T10:30"));
385    }
386
387    #[test]
388    fn agent_compacts_recent_timestamp_to_relative() {
389        // 3 minutes before NOW: diff = 180s.
390        let ts_unix = NOW - 180;
391        // Format as ISO-8601.
392        let ts = unix_to_iso8601(ts_unix);
393        let v = json!({"updated_at": ts});
394        let out = agent(v);
395        assert_eq!(out["updated_at"], json!("3m ago"));
396    }
397
398    #[test]
399    fn agent_recurses_into_nested_objects() {
400        let v = json!({
401            "items": [
402                {
403                    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
404                    "tags": [],
405                    "score": 0.9999
406                }
407            ]
408        });
409        let out = agent(v);
410        let item = &out["items"][0];
411        assert_eq!(item["id"], json!("a1b2c3d4"));
412        assert!(item.get("tags").is_none());
413        let s = item["score"].as_f64().unwrap();
414        assert!((s - 1.0).abs() < 1e-9);
415    }
416
417    #[test]
418    fn is_canonical_uuid_recognizes_valid() {
419        assert!(is_canonical_uuid("a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
420        assert!(!is_canonical_uuid("a1b2c3d4"));
421        assert!(!is_canonical_uuid("not-a-uuid-at-all-here---------"));
422    }
423
424    #[test]
425    fn looks_like_iso8601_recognizes_valid() {
426        assert!(looks_like_iso8601("2026-05-23T16:18:15.234567Z"));
427        assert!(!looks_like_iso8601("not a timestamp"));
428        assert!(!looks_like_iso8601("2026-05-23"));
429    }
430
431    /// Format Unix seconds as ISO-8601 for test construction.
432    fn unix_to_iso8601(unix: i64) -> String {
433        let (y, mo, d, h, mi, s) = unix_to_civil(unix);
434        format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
435    }
436
437    fn unix_to_civil(unix: i64) -> (i64, i64, i64, i64, i64, i64) {
438        let s = unix % 86400;
439        let days = unix / 86400;
440        let h = s / 3600;
441        let m = (s % 3600) / 60;
442        let sec = s % 60;
443        // Howard Hinnant civil_from_days
444        let z = days + 719468;
445        let era = z.div_euclid(146097);
446        let doe = z - era * 146097;
447        let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
448        let y = yoe + era * 400;
449        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
450        let mp = (5 * doy + 2) / 153;
451        let d = doy - (153 * mp + 2) / 5 + 1;
452        let mo = if mp < 10 { mp + 3 } else { mp - 9 };
453        let y = if mo <= 2 { y + 1 } else { y };
454        (y, mo, d, h, m, sec)
455    }
456}