Skip to main content

locus_core_rs/parsing/
document_builder.rs

1//! Fluent STTP document construction with shallow content-layer merge.
2//!
3//! Merge is intentionally limited: slices may only contribute **top-level content
4//! keys**. Provenance and envelope come from metadata; metrics are finalized at
5//! [`SttpDocumentBuilder::build`]. Nested objects inside a top-level field are
6//! opaque and are never deep-merged.
7
8use std::collections::BTreeMap;
9use std::fmt;
10
11use chrono::{DateTime, Utc};
12use serde_json::{Map, Value};
13
14use crate::domain::models::{AvecState, SemanticLink};
15
16/// Seed data for provenance + envelope layers.
17#[derive(Debug, Clone)]
18pub struct SttpDocumentMetadata {
19    pub session_id: String,
20    pub timestamp: DateTime<Utc>,
21    pub tier: String,
22    pub trigger: String,
23    pub response_format: String,
24    pub compression_depth: i32,
25    pub parent_node: Option<String>,
26    pub context_summary: String,
27    pub retrieval_budget: i32,
28    pub attractor_config: AvecState,
29    pub user_avec: AvecState,
30    pub model_avec: AvecState,
31    pub semantic_tags: Option<Vec<String>>,
32    pub semantic_links: Option<Vec<SemanticLink>>,
33    pub schema_version: Option<String>,
34    /// Optional metrics overrides; unset fields are derived at build time.
35    pub rho: Option<f32>,
36    pub kappa: Option<f32>,
37    pub compression_avec: Option<AvecState>,
38}
39
40impl SttpDocumentMetadata {
41    pub fn new(session_id: impl Into<String>) -> Self {
42        let session_id = session_id.into();
43        let attractor = AvecState::analytical();
44        Self {
45            session_id,
46            timestamp: Utc::now(),
47            tier: "raw".to_string(),
48            trigger: "manual".to_string(),
49            response_format: "temporal_node".to_string(),
50            compression_depth: 1,
51            parent_node: None,
52            context_summary: String::new(),
53            retrieval_budget: 5,
54            attractor_config: attractor,
55            user_avec: attractor,
56            model_avec: attractor,
57            semantic_tags: None,
58            semantic_links: None,
59            schema_version: Some("sttp-1.2".to_string()),
60            rho: None,
61            kappa: None,
62            compression_avec: None,
63        }
64    }
65
66    pub fn with_timestamp(mut self, timestamp: DateTime<Utc>) -> Self {
67        self.timestamp = timestamp;
68        self
69    }
70
71    pub fn with_tier(mut self, tier: impl Into<String>) -> Self {
72        self.tier = tier.into();
73        self
74    }
75
76    pub fn with_context_summary(mut self, summary: impl Into<String>) -> Self {
77        self.context_summary = summary.into();
78        self
79    }
80
81    pub fn with_avec(mut self, user: AvecState, model: AvecState) -> Self {
82        self.user_avec = user;
83        self.model_avec = model;
84        self.attractor_config = user;
85        self
86    }
87
88    pub fn with_semantic_tags(mut self, tags: Vec<String>) -> Self {
89        self.semantic_tags = Some(tags);
90        self
91    }
92
93    pub fn with_semantic_links(mut self, links: Vec<SemanticLink>) -> Self {
94        self.semantic_links = Some(links);
95        self
96    }
97}
98
99/// A content-layer contribution limited to top-level keys.
100///
101/// Keys must use the canonical confidence signature: `field_name(.confidence)`.
102#[derive(Debug, Clone, Default)]
103pub struct SttpContentSlice {
104    fields: Map<String, Value>,
105}
106
107impl SttpContentSlice {
108    pub fn new() -> Self {
109        Self {
110            fields: Map::new(),
111        }
112    }
113
114    /// Insert a top-level content field with an explicit confidence annotation.
115    pub fn field(
116        mut self,
117        name: impl Into<String>,
118        confidence: f32,
119        value: Value,
120    ) -> Result<Self, SttpDocumentBuildError> {
121        let name = name.into();
122        validate_identifier(&name)?;
123        validate_confidence(confidence)?;
124        let key = format_content_key(&name, confidence);
125        if content_field_name_occupied(&self.fields, &name) {
126            return Err(SttpDocumentBuildError::DuplicateContentField(name));
127        }
128        self.fields.insert(key, value);
129        Ok(self)
130    }
131
132    /// Build a slice from a map whose keys already include confidence signatures.
133    pub fn from_confidence_map(map: Map<String, Value>) -> Result<Self, SttpDocumentBuildError> {
134        let mut slice = Self::new();
135        for (key, value) in map {
136            let name = parse_content_field_name(&key).ok_or_else(|| {
137                SttpDocumentBuildError::InvalidContentKey(key.clone())
138            })?;
139            if content_field_name_occupied(&slice.fields, name) {
140                return Err(SttpDocumentBuildError::DuplicateContentField(name.to_string()));
141            }
142            slice.fields.insert(key, value);
143        }
144        Ok(slice)
145    }
146
147    pub fn is_empty(&self) -> bool {
148        self.fields.is_empty()
149    }
150
151    pub fn len(&self) -> usize {
152        self.fields.len()
153    }
154}
155
156/// Fluent builder: metadata → shallow content merges → built document.
157#[derive(Debug, Clone)]
158pub struct SttpDocumentBuilder {
159    metadata: SttpDocumentMetadata,
160    /// Top-level content fields keyed by full `name(.confidence)` string.
161    content: Map<String, Value>,
162    /// Field names (without confidence) already claimed, for collision checks.
163    claimed_names: BTreeMap<String, String>,
164}
165
166impl SttpDocumentBuilder {
167    pub fn new(metadata: SttpDocumentMetadata) -> Self {
168        Self {
169            metadata,
170            content: Map::new(),
171            claimed_names: BTreeMap::new(),
172        }
173    }
174
175    /// Shallow-merge a content slice at the top-level key layer only.
176    ///
177    /// Duplicate field **names** (ignoring confidence) are rejected.
178    pub fn merge(mut self, slice: SttpContentSlice) -> Result<Self, SttpDocumentBuildError> {
179        for (key, value) in slice.fields {
180            let name = parse_content_field_name(&key)
181                .ok_or_else(|| SttpDocumentBuildError::InvalidContentKey(key.clone()))?
182                .to_string();
183            if let Some(existing_key) = self.claimed_names.get(&name) {
184                return Err(SttpDocumentBuildError::DuplicateContentField(format!(
185                    "{name} (already present as {existing_key})"
186                )));
187            }
188            self.claimed_names.insert(name, key.clone());
189            self.content.insert(key, value);
190        }
191        Ok(self)
192    }
193
194    /// Finalize metrics and produce a document ready for canonical render.
195    pub fn build(self) -> Result<SttpDocument, SttpDocumentBuildError> {
196        validate_metadata(&self.metadata)?;
197        if self.content.is_empty() {
198            return Err(SttpDocumentBuildError::EmptyContent);
199        }
200
201        let compression_avec = self
202            .metadata
203            .compression_avec
204            .unwrap_or(self.metadata.user_avec);
205        let psi = compression_avec.psi();
206        let rho = self.metadata.rho.unwrap_or(0.95);
207        let kappa = self.metadata.kappa.unwrap_or(0.94);
208        validate_confidence(rho)?;
209        validate_confidence(kappa)?;
210
211        Ok(SttpDocument {
212            metadata: self.metadata,
213            content: self.content,
214            rho,
215            kappa,
216            psi,
217            compression_avec,
218        })
219    }
220}
221
222/// Built STTP document: structured layers ready for canonical rendering.
223#[derive(Debug, Clone)]
224pub struct SttpDocument {
225    metadata: SttpDocumentMetadata,
226    content: Map<String, Value>,
227    rho: f32,
228    kappa: f32,
229    psi: f32,
230    compression_avec: AvecState,
231}
232
233impl SttpDocument {
234    pub fn content(&self) -> &Map<String, Value> {
235        &self.content
236    }
237
238    pub fn metadata(&self) -> &SttpDocumentMetadata {
239        &self.metadata
240    }
241
242    /// Emit strict-profile canonical STTP wire text (four-layer spine).
243    pub fn render_canonical(&self) -> String {
244        let provenance = render_provenance(&self.metadata);
245        let envelope = render_envelope(&self.metadata);
246        let content = render_sttp_object(&self.content);
247        let metrics = render_metrics(self.rho, self.kappa, self.psi, self.compression_avec);
248
249        format!(
250            "⊕⟨ {provenance} ⟩\n⦿⟨ {envelope} ⟩\n◈⟨ {content} ⟩\n⍉⟨ {metrics} ⟩"
251        )
252    }
253}
254
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub enum SttpDocumentBuildError {
257    EmptyContent,
258    DuplicateContentField(String),
259    InvalidContentKey(String),
260    InvalidIdentifier(String),
261    InvalidConfidence(String),
262    InvalidMetadata(String),
263}
264
265impl fmt::Display for SttpDocumentBuildError {
266    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267        match self {
268            Self::EmptyContent => write!(f, "content layer must contain at least one field"),
269            Self::DuplicateContentField(name) => {
270                write!(f, "duplicate top-level content field: {name}")
271            }
272            Self::InvalidContentKey(key) => {
273                write!(
274                    f,
275                    "content key must match field_name(.confidence): found '{key}'"
276                )
277            }
278            Self::InvalidIdentifier(name) => {
279                write!(f, "invalid content field identifier: '{name}'")
280            }
281            Self::InvalidConfidence(detail) => write!(f, "invalid confidence: {detail}"),
282            Self::InvalidMetadata(detail) => write!(f, "invalid document metadata: {detail}"),
283        }
284    }
285}
286
287impl std::error::Error for SttpDocumentBuildError {}
288
289fn validate_metadata(meta: &SttpDocumentMetadata) -> Result<(), SttpDocumentBuildError> {
290    if meta.session_id.trim().is_empty() {
291        return Err(SttpDocumentBuildError::InvalidMetadata(
292            "session_id must be non-empty".to_string(),
293        ));
294    }
295    if meta.context_summary.is_empty() {
296        return Err(SttpDocumentBuildError::InvalidMetadata(
297            "context_summary must be non-empty".to_string(),
298        ));
299    }
300    validate_enum(
301        "trigger",
302        &meta.trigger,
303        &["scheduled", "threshold", "resonance", "seed", "manual"],
304    )?;
305    validate_enum(
306        "response_format",
307        &meta.response_format,
308        &["temporal_node", "natural_language", "hybrid"],
309    )?;
310    validate_enum(
311        "tier",
312        &meta.tier,
313        &["raw", "daily", "weekly", "monthly", "quarterly", "yearly"],
314    )?;
315    validate_enum(
316        "relevant_tier",
317        &meta.tier,
318        &["raw", "daily", "weekly", "monthly", "quarterly", "yearly"],
319    )?;
320    if let Some(tags) = &meta.semantic_tags {
321        if tags.is_empty() {
322            return Err(SttpDocumentBuildError::InvalidMetadata(
323                "semantic_tags when present must be non-empty".to_string(),
324            ));
325        }
326    }
327    if let Some(links) = &meta.semantic_links {
328        if links.is_empty() {
329            return Err(SttpDocumentBuildError::InvalidMetadata(
330                "semantic_links when present must be non-empty".to_string(),
331            ));
332        }
333        for link in links {
334            if link.rel.trim().is_empty() || link.target.trim().is_empty() {
335                return Err(SttpDocumentBuildError::InvalidMetadata(
336                    "semantic_links entries require non-empty rel and target".to_string(),
337                ));
338            }
339            if let Some(confidence) = link.confidence {
340                validate_confidence(confidence)?;
341            }
342        }
343    }
344    Ok(())
345}
346
347fn validate_enum(
348    field: &str,
349    value: &str,
350    allowed: &[&str],
351) -> Result<(), SttpDocumentBuildError> {
352    if allowed.contains(&value) {
353        Ok(())
354    } else {
355        Err(SttpDocumentBuildError::InvalidMetadata(format!(
356            "{field} must be one of {:?}, found '{value}'",
357            allowed
358        )))
359    }
360}
361
362fn validate_identifier(name: &str) -> Result<(), SttpDocumentBuildError> {
363    let valid = !name.is_empty()
364        && name
365            .chars()
366            .enumerate()
367            .all(|(idx, ch)| match idx {
368                0 => ch.is_ascii_alphabetic() || ch == '_',
369                _ => ch.is_ascii_alphanumeric() || ch == '_',
370            });
371    if valid {
372        Ok(())
373    } else {
374        Err(SttpDocumentBuildError::InvalidIdentifier(name.to_string()))
375    }
376}
377
378fn validate_confidence(value: f32) -> Result<(), SttpDocumentBuildError> {
379    if (0.0..=1.0).contains(&value) && value.is_finite() {
380        Ok(())
381    } else {
382        Err(SttpDocumentBuildError::InvalidConfidence(format!(
383            "expected [0.0, 1.0], found {value}"
384        )))
385    }
386}
387
388fn format_content_key(name: &str, confidence: f32) -> String {
389    // Canonical form matches language fixtures: field(.98)
390    let text = format!("{confidence:.2}");
391    let body = text
392        .strip_prefix("0.")
393        .map(|rest| format!(".{rest}"))
394        .unwrap_or(text);
395    format!("{name}({body})")
396}
397
398fn parse_content_field_name(raw_key: &str) -> Option<&str> {
399    let open = raw_key.find('(')?;
400    let close = raw_key.rfind(')')?;
401    if close <= open + 1 || close != raw_key.len() - 1 {
402        return None;
403    }
404    let name = raw_key[..open].trim();
405    if name.is_empty() {
406        return None;
407    }
408    let confidence_text = raw_key[open + 1..close].trim();
409    let confidence = confidence_text.parse::<f32>().ok()?;
410    if !(0.0..=1.0).contains(&confidence) {
411        return None;
412    }
413    Some(name)
414}
415
416fn content_field_name_occupied(fields: &Map<String, Value>, name: &str) -> bool {
417    fields.keys().any(|key| parse_content_field_name(key) == Some(name))
418}
419
420fn render_provenance(meta: &SttpDocumentMetadata) -> String {
421    let parent = match &meta.parent_node {
422        Some(id) => format!("\"{}\"", escape_string(id)),
423        None => "null".to_string(),
424    };
425    let attractor = render_avec_body(meta.attractor_config, false);
426    let mut prime = format!(
427        "{{ attractor_config: {{ {attractor} }}, context_summary: \"{}\", relevant_tier: {}, retrieval_budget: {}",
428        escape_string(&meta.context_summary),
429        meta.tier,
430        meta.retrieval_budget
431    );
432    if let Some(tags) = &meta.semantic_tags {
433        let rendered = canonicalize_tags(tags)
434            .into_iter()
435            .map(|tag| format!("\"{}\"", escape_string(&tag)))
436            .collect::<Vec<_>>()
437            .join(", ");
438        prime.push_str(&format!(", semantic_tags: [{rendered}]"));
439    }
440    prime.push_str(" }");
441
442    let mut body = format!(
443        "{{ trigger: {}, response_format: {}, origin_session: \"{}\", compression_depth: {}, parent_node: {}, prime: {prime}",
444        meta.trigger,
445        meta.response_format,
446        escape_string(&meta.session_id),
447        meta.compression_depth,
448        parent
449    );
450    if let Some(links) = &meta.semantic_links {
451        let rendered = links
452            .iter()
453            .map(render_semantic_link)
454            .collect::<Vec<_>>()
455            .join(", ");
456        body.push_str(&format!(", semantic_links: [{rendered}]"));
457    }
458    body.push_str(" }");
459    body
460}
461
462fn render_envelope(meta: &SttpDocumentMetadata) -> String {
463    let user = render_avec_body(meta.user_avec, true);
464    let model = render_avec_body(meta.model_avec, true);
465    let mut body = format!(
466        "{{ timestamp: \"{}\", tier: {}, session_id: \"{}\"",
467        meta.timestamp.to_rfc3339(),
468        meta.tier,
469        escape_string(&meta.session_id)
470    );
471    if let Some(version) = &meta.schema_version {
472        body.push_str(&format!(
473            ", schema_version: \"{}\"",
474            escape_string(version)
475        ));
476    }
477    body.push_str(&format!(
478        ", user_avec: {{ {user} }}, model_avec: {{ {model} }} }}"
479    ));
480    body
481}
482
483fn render_metrics(rho: f32, kappa: f32, psi: f32, compression_avec: AvecState) -> String {
484    let avec = render_avec_body(compression_avec, true);
485    format!(
486        "{{ rho: {}, kappa: {}, psi: {}, compression_avec: {{ {avec} }} }}",
487        format_float(rho),
488        format_float(kappa),
489        format_float(psi)
490    )
491}
492
493fn render_avec_body(avec: AvecState, include_psi: bool) -> String {
494    if include_psi {
495        format!(
496            "stability: {}, friction: {}, logic: {}, autonomy: {}, psi: {}",
497            format_float(avec.stability),
498            format_float(avec.friction),
499            format_float(avec.logic),
500            format_float(avec.autonomy),
501            format_float(avec.psi())
502        )
503    } else {
504        format!(
505            "stability: {}, friction: {}, logic: {}, autonomy: {}",
506            format_float(avec.stability),
507            format_float(avec.friction),
508            format_float(avec.logic),
509            format_float(avec.autonomy)
510        )
511    }
512}
513
514fn render_semantic_link(link: &SemanticLink) -> String {
515    let mut body = format!(
516        "{{ rel: \"{}\", target: \"{}\"",
517        escape_string(&link.rel),
518        escape_string(&link.target)
519    );
520    if let Some(confidence) = link.confidence {
521        body.push_str(&format!(", confidence: {}", format_float(confidence)));
522    }
523    body.push_str(" }");
524    body
525}
526
527fn render_sttp_value(value: &Value) -> String {
528    match value {
529        Value::Null => "null".to_string(),
530        Value::Bool(v) => v.to_string(),
531        Value::Number(v) => v.to_string(),
532        Value::String(v) => format!("\"{}\"", escape_string(v)),
533        Value::Array(values) => {
534            let rendered = values
535                .iter()
536                .map(render_sttp_value)
537                .collect::<Vec<_>>()
538                .join(", ");
539            format!("[{rendered}]")
540        }
541        Value::Object(obj) => render_sttp_object(obj),
542    }
543}
544
545fn render_sttp_object(obj: &Map<String, Value>) -> String {
546    let rendered = obj
547        .iter()
548        .map(|(key, value)| format!("{key}: {}", render_sttp_value(value)))
549        .collect::<Vec<_>>()
550        .join(", ");
551    format!("{{ {rendered} }}")
552}
553
554fn canonicalize_tags(tags: &[String]) -> Vec<String> {
555    let mut out: Vec<String> = tags
556        .iter()
557        .map(|tag| tag.trim().to_lowercase())
558        .filter(|tag| !tag.is_empty())
559        .collect();
560    out.sort();
561    out.dedup();
562    out
563}
564
565fn escape_string(input: &str) -> String {
566    input.replace('\\', "\\\\").replace('"', "\\\"")
567}
568
569fn format_float(value: f32) -> String {
570    let rounded = (value * 100.0).round() / 100.0;
571    if (rounded - rounded.trunc()).abs() < f32::EPSILON {
572        format!("{:.1}", rounded)
573    } else {
574        let text = format!("{rounded}");
575        if text.contains('.') {
576            text.trim_end_matches('0').trim_end_matches('.').to_string()
577        } else {
578            text
579        }
580    }
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586    use crate::application::validation::TreeSitterValidator;
587    use crate::domain::contracts::NodeValidator;
588    use crate::parsing::SttpNodeParser;
589    use chrono::TimeZone;
590    use serde_json::json;
591
592    fn sample_metadata() -> SttpDocumentMetadata {
593        SttpDocumentMetadata::new("builder-session")
594            .with_timestamp(
595                Utc.with_ymd_and_hms(2026, 8, 25, 12, 0, 0)
596                    .single()
597                    .expect("valid timestamp"),
598            )
599            .with_context_summary("document builder smoke")
600            .with_avec(AvecState::analytical(), AvecState::analytical())
601    }
602
603    #[test]
604    fn merge_rejects_duplicate_top_level_field_names() {
605        let core = SttpContentSlice::new()
606            .field("core", 0.98, json!({"note(.99)": "a"}))
607            .expect("core slice");
608        let conflict = SttpContentSlice::new()
609            .field("core", 0.50, json!({"note(.99)": "b"}))
610            .expect("conflict slice");
611
612        let result = SttpDocumentBuilder::new(sample_metadata())
613            .merge(core)
614            .expect("first merge")
615            .merge(conflict);
616
617        assert!(matches!(
618            result,
619            Err(SttpDocumentBuildError::DuplicateContentField(_))
620        ));
621    }
622
623    #[test]
624    fn build_requires_non_empty_content() {
625        let result = SttpDocumentBuilder::new(sample_metadata()).build();
626        assert_eq!(result.unwrap_err(), SttpDocumentBuildError::EmptyContent);
627    }
628
629    #[test]
630    fn fluent_merge_build_render_round_trips_strict_typed_ir() {
631        let metadata = sample_metadata()
632            .with_semantic_tags(vec!["Core".to_string(), "parser".to_string()])
633            .with_semantic_links(vec![SemanticLink {
634                rel: "related_to".to_string(),
635                target: "concept:document-builder".to_string(),
636                confidence: Some(0.88),
637            }]);
638
639        let core = SttpContentSlice::new()
640            .field(
641                "core",
642                0.98,
643                json!({
644                    "focus(.99)": "grammar",
645                    "decision(.96)": { "parser_mode(.95)": "strict_and_tolerant" }
646                }),
647            )
648            .expect("core");
649        let mode = SttpContentSlice::new()
650            .field("mode", 0.97, json!({ "profile(.99)": "strict" }))
651            .expect("mode");
652        let turn = SttpContentSlice::new()
653            .field("turn", 0.96, json!({ "utterance(.90)": "lock merge at top level" }))
654            .expect("turn");
655
656        let rendered = SttpDocumentBuilder::new(metadata)
657            .merge(core)
658            .expect("merge core")
659            .merge(mode)
660            .expect("merge mode")
661            .merge(turn)
662            .expect("merge turn")
663            .build()
664            .expect("build")
665            .render_canonical();
666
667        let validator = TreeSitterValidator::new();
668        let validation = validator.validate(&rendered);
669        assert!(validation.is_valid, "{:?}", validation.error);
670
671        let parsed = SttpNodeParser::new().try_parse_strict_typed_ir(&rendered, "builder-session");
672        assert!(parsed.success, "{:?}", parsed.error);
673        assert!(parsed.strict_valid);
674
675        let node = parsed.node.expect("node");
676        assert_eq!(
677            node.semantic_tags,
678            Some(vec!["core".to_string(), "parser".to_string()])
679        );
680        assert!(rendered.contains("core(.98):"));
681        assert!(rendered.contains("mode(.97):"));
682        assert!(rendered.contains("turn(.96):"));
683    }
684}