1use 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#[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 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#[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 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 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#[derive(Debug, Clone)]
158pub struct SttpDocumentBuilder {
159 metadata: SttpDocumentMetadata,
160 content: Map<String, Value>,
162 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 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 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#[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 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 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}