Skip to main content

ferrocat_po/api/
mt.rs

1use serde::{Deserialize, Serialize};
2use sha2::{Digest, Sha256};
3
4use super::types::{ApiError, EffectiveTranslationRef};
5
6/// PO metadata-comment key for the machine-managed integrity lock (`#@ lock …`).
7pub(super) const PO_LOCK_KEY: &str = "lock";
8/// PO metadata-comment key for AI provenance (`#@ ai …`).
9pub(super) const PO_AI_KEY: &str = "ai";
10
11const MACHINE_TRANSLATION_HASH_NAMESPACE: &[u8] = b"ferrocat:mt:v1";
12
13/// Metadata for a catalog value that was set or managed by a machine.
14///
15/// See [ADR 0022](https://sebastian-software.github.io/ferrocat/architecture/adr/0022-machine-managed-value-integrity-and-ai-provenance).
16/// The presence of this metadata marks the value as machine-managed (by an AI
17/// engine, a translation memory system, a script, …). [`lock`](Self::lock) is an
18/// integrity fingerprint of the value at that time: when it no longer matches the
19/// current value, a human edited it, and high-level writers drop the metadata
20/// instead of carrying a stale machine fact forward.
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct MachineMetadata {
24    /// Integrity fingerprint of the value at the time a machine set it.
25    pub lock: String,
26    /// Optional AI provenance, when the machine was an AI engine.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub ai: Option<AiProvenance>,
29}
30
31/// AI provenance for a machine-managed value.
32///
33/// Engine-agnostic: any producer (Palamedes, a gateway, a TMS with an AI step)
34/// can fill it. Ferrocat understands it for AI-native reporting but treats the
35/// model identifier as an opaque, free-form string.
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct AiProvenance {
39    /// Opaque model identifier, for example `openai/gpt-5.5-high` or `opus-4-8`.
40    ///
41    /// Whether it carries a provider prefix is the producer's choice; Ferrocat
42    /// does not parse it.
43    pub model: String,
44    /// Optional model confidence in the closed range `[0, 1]`.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub confidence: Option<f32>,
47}
48
49/// Computes the integrity-lock hash for a translation value.
50///
51/// The hash is SHA-256 over a versioned, length-delimited canonical translation
52/// payload plus Ferrocat's fixed `ferrocat:mt:v1` namespace. The namespace is
53/// intentionally not secret; the hash detects accidental or manual changes and is
54/// not a cryptographic signature.
55///
56/// The emitted value is the first 128 bits encoded as unpadded Base64URL.
57///
58/// # Examples
59///
60/// ```rust
61/// use ferrocat_po::{EffectiveTranslationRef, machine_translation_hash};
62///
63/// let hash = machine_translation_hash(EffectiveTranslationRef::Singular("Hallo"));
64/// assert!(!hash.is_empty());
65/// ```
66#[must_use]
67pub fn machine_translation_hash(translation: EffectiveTranslationRef<'_>) -> String {
68    let mut payload = Vec::new();
69    payload.extend_from_slice(MACHINE_TRANSLATION_HASH_NAMESPACE);
70    match translation {
71        EffectiveTranslationRef::Singular(value) => {
72            push_hash_component(&mut payload, "singular");
73            push_hash_component(&mut payload, value);
74        }
75        EffectiveTranslationRef::Plural(values) => {
76            push_hash_component(&mut payload, "plural");
77            let count = u32::try_from(values.len()).expect("plural translation count exceeds u32");
78            payload.extend_from_slice(&count.to_be_bytes());
79            for (category, value) in values {
80                push_hash_component(&mut payload, category);
81                push_hash_component(&mut payload, value);
82            }
83        }
84    }
85    let digest = Sha256::digest(&payload);
86    base64_url_no_pad(&digest[..16])
87}
88
89pub(super) fn validate_machine_metadata(metadata: &MachineMetadata) -> Result<(), ApiError> {
90    if metadata.lock.trim().is_empty() {
91        return Err(ApiError::InvalidArguments(
92            "machine-managed lock must not be empty".to_owned(),
93        ));
94    }
95    if let Some(ai) = &metadata.ai {
96        if ai.model.trim().is_empty() {
97            return Err(ApiError::InvalidArguments(
98                "ai model must not be empty".to_owned(),
99            ));
100        }
101        if ai
102            .confidence
103            .is_some_and(|confidence| !(0.0..=1.0).contains(&confidence))
104        {
105            return Err(ApiError::InvalidArguments(
106                "ai confidence must be between 0 and 1".to_owned(),
107            ));
108        }
109    }
110    Ok(())
111}
112
113/// Serializes an [`AiProvenance`] to the shared `model[:confidence]` descriptor
114/// used by both PO and FCL.
115pub(super) fn format_ai_descriptor(ai: &AiProvenance) -> String {
116    match ai.confidence {
117        Some(confidence) => format!("{}:{confidence}", ai.model),
118        None => ai.model.clone(),
119    }
120}
121
122/// Parses the shared `model[:confidence]` descriptor.
123///
124/// The model identifier is free-form, so the confidence is taken from after the
125/// final `:` only when it is a valid `[0, 1]` decimal; otherwise the whole string
126/// is the model (mirroring the origin `file:line` heuristic). This keeps model
127/// ids that contain `/` or `:` intact and needs no escaping.
128pub(super) fn parse_ai_descriptor(value: &str) -> AiProvenance {
129    if let Some((model, suffix)) = value.rsplit_once(':')
130        && let Some(confidence) = parse_confidence(suffix)
131    {
132        return AiProvenance {
133            model: model.to_owned(),
134            confidence: Some(confidence),
135        };
136    }
137    AiProvenance {
138        model: value.to_owned(),
139        confidence: None,
140    }
141}
142
143fn parse_confidence(value: &str) -> Option<f32> {
144    value
145        .parse::<f32>()
146        .ok()
147        .filter(|confidence| (0.0..=1.0).contains(confidence))
148}
149
150fn push_hash_component(out: &mut Vec<u8>, value: &str) {
151    let value_len =
152        u32::try_from(value.len()).expect("machine translation hash component exceeds u32");
153    out.extend_from_slice(&value_len.to_be_bytes());
154    out.extend_from_slice(value.as_bytes());
155}
156
157fn base64_url_no_pad(bytes: &[u8]) -> String {
158    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
159    let mut out = String::with_capacity((bytes.len() * 4).div_ceil(3));
160    let mut index = 0;
161
162    while index + 3 <= bytes.len() {
163        let chunk = (u32::from(bytes[index]) << 16)
164            | (u32::from(bytes[index + 1]) << 8)
165            | u32::from(bytes[index + 2]);
166        out.push(ALPHABET[((chunk >> 18) & 0x3f) as usize] as char);
167        out.push(ALPHABET[((chunk >> 12) & 0x3f) as usize] as char);
168        out.push(ALPHABET[((chunk >> 6) & 0x3f) as usize] as char);
169        out.push(ALPHABET[(chunk & 0x3f) as usize] as char);
170        index += 3;
171    }
172
173    match bytes.len() - index {
174        1 => {
175            let chunk = u32::from(bytes[index]) << 16;
176            out.push(ALPHABET[((chunk >> 18) & 0x3f) as usize] as char);
177            out.push(ALPHABET[((chunk >> 12) & 0x3f) as usize] as char);
178        }
179        2 => {
180            let chunk = (u32::from(bytes[index]) << 16) | (u32::from(bytes[index + 1]) << 8);
181            out.push(ALPHABET[((chunk >> 18) & 0x3f) as usize] as char);
182            out.push(ALPHABET[((chunk >> 12) & 0x3f) as usize] as char);
183            out.push(ALPHABET[((chunk >> 6) & 0x3f) as usize] as char);
184        }
185        _ => {}
186    }
187
188    out
189}
190
191#[cfg(test)]
192mod tests {
193    use super::{
194        AiProvenance, MachineMetadata, format_ai_descriptor, machine_translation_hash,
195        parse_ai_descriptor, validate_machine_metadata,
196    };
197    use crate::api::EffectiveTranslationRef;
198    use std::collections::BTreeMap;
199
200    #[test]
201    fn hash_is_stable_for_singular_and_plural_translations() {
202        let singular = machine_translation_hash(EffectiveTranslationRef::Singular("Hallo"));
203        assert_eq!(
204            singular,
205            machine_translation_hash(EffectiveTranslationRef::Singular("Hallo"))
206        );
207        assert_ne!(
208            singular,
209            machine_translation_hash(EffectiveTranslationRef::Singular("Hello"))
210        );
211
212        let plural = BTreeMap::from([
213            ("one".to_owned(), "Eine Datei".to_owned()),
214            ("other".to_owned(), "{count} Dateien".to_owned()),
215        ]);
216        let repeated = BTreeMap::from([
217            ("one".to_owned(), "Eine Datei".to_owned()),
218            ("other".to_owned(), "{count} Dateien".to_owned()),
219        ]);
220        assert_eq!(
221            machine_translation_hash(EffectiveTranslationRef::Plural(&plural)),
222            machine_translation_hash(EffectiveTranslationRef::Plural(&repeated))
223        );
224        assert_eq!(singular.len(), 22);
225    }
226
227    #[test]
228    fn ai_descriptor_roundtrips_and_keeps_free_form_model() {
229        // model with provider slash + confidence
230        let ai = AiProvenance {
231            model: "openai/gpt-5.5-high".to_owned(),
232            confidence: Some(0.93),
233        };
234        let rendered = format_ai_descriptor(&ai);
235        assert_eq!(rendered, "openai/gpt-5.5-high:0.93");
236        assert_eq!(parse_ai_descriptor(&rendered), ai);
237
238        // no confidence, both directions
239        let bare = AiProvenance {
240            model: "grok-4".to_owned(),
241            confidence: None,
242        };
243        assert_eq!(format_ai_descriptor(&bare), "grok-4");
244        assert_eq!(parse_ai_descriptor("grok-4"), bare);
245
246        // a non-numeric or out-of-range suffix is part of the model, not confidence
247        assert_eq!(
248            parse_ai_descriptor("vendor:model-x"),
249            AiProvenance {
250                model: "vendor:model-x".to_owned(),
251                confidence: None,
252            }
253        );
254        assert_eq!(
255            parse_ai_descriptor("weird-model:1.5"),
256            AiProvenance {
257                model: "weird-model:1.5".to_owned(),
258                confidence: None,
259            }
260        );
261    }
262
263    #[test]
264    fn validate_rejects_bad_metadata() {
265        assert!(
266            validate_machine_metadata(&MachineMetadata {
267                lock: " ".to_owned(),
268                ai: None,
269            })
270            .is_err()
271        );
272        assert!(
273            validate_machine_metadata(&MachineMetadata {
274                lock: "abc".to_owned(),
275                ai: Some(AiProvenance {
276                    model: "".to_owned(),
277                    confidence: None,
278                }),
279            })
280            .is_err()
281        );
282        assert!(
283            validate_machine_metadata(&MachineMetadata {
284                lock: "abc".to_owned(),
285                ai: Some(AiProvenance {
286                    model: "m".to_owned(),
287                    confidence: Some(1.5),
288                }),
289            })
290            .is_err()
291        );
292        assert!(
293            validate_machine_metadata(&MachineMetadata {
294                lock: "abc".to_owned(),
295                ai: Some(AiProvenance {
296                    model: "m".to_owned(),
297                    confidence: Some(0.5),
298                }),
299            })
300            .is_ok()
301        );
302    }
303
304    #[test]
305    fn base64_url_encodes_every_remainder() {
306        // The hash always feeds 16 bytes (1-byte remainder); cover the encoder's
307        // other tail cases directly so the helper is fully exercised.
308        assert_eq!(super::base64_url_no_pad(b""), "");
309        assert_eq!(super::base64_url_no_pad(b"f"), "Zg");
310        assert_eq!(super::base64_url_no_pad(b"fo"), "Zm8");
311        assert_eq!(super::base64_url_no_pad(b"foo"), "Zm9v");
312    }
313}