1use chrono::{DateTime, Utc};
2
3use crate::domain::models::{
4 AvecState, CanonicalAst, CanonicalAstLayer, ParseDiagnostic, ParseDiagnosticSeverity,
5 ParseProfile, ParseResult, ParseSpan, SttpNode,
6};
7use crate::parsing::lexicon::{AVEC_COMPRESSION_KEY, AVEC_MODEL_KEY, AVEC_USER_KEY};
8use crate::parsing::state_machine::{ParserState, SttpLayerStateMachine};
9
10#[derive(Debug, Clone, Copy)]
11struct ContentKeySignature<'a> {
12 name: &'a str,
13 confidence: f32,
14}
15
16#[derive(Debug, Clone, Copy)]
17enum LayerScope {
18 Provenance,
19 Envelope,
20 Metrics,
21}
22
23impl LayerScope {
24 fn name(self) -> &'static str {
25 match self {
26 LayerScope::Provenance => "provenance",
27 LayerScope::Envelope => "envelope",
28 LayerScope::Metrics => "metrics",
29 }
30 }
31}
32
33#[derive(Debug, Clone, Copy)]
34enum ObjectScope {
35 ProvenancePrime,
36 ProvenanceAttractorConfig,
37 EnvelopeUserAvec,
38 EnvelopeModelAvec,
39 MetricsCompressionAvec,
40}
41
42impl ObjectScope {
43 fn path(self) -> &'static str {
44 match self {
45 ObjectScope::ProvenancePrime => "provenance.prime",
46 ObjectScope::ProvenanceAttractorConfig => "provenance.prime.attractor_config",
47 ObjectScope::EnvelopeUserAvec => "envelope.user_avec",
48 ObjectScope::EnvelopeModelAvec => "envelope.model_avec",
49 ObjectScope::MetricsCompressionAvec => "metrics.compression_avec",
50 }
51 }
52}
53
54#[derive(Debug, Clone, Copy)]
55enum NodeFieldKey {
56 Trigger,
57 ResponseFormat,
58 OriginSession,
59 CompressionDepth,
60 ParentNode,
61 Prime,
62 AttractorConfig,
63 ContextSummary,
64 RelevantTier,
65 RetrievalBudget,
66 Timestamp,
67 Tier,
68 SessionId,
69 UserAvec,
70 ModelAvec,
71 Stability,
72 Friction,
73 Logic,
74 Autonomy,
75 Psi,
76 Rho,
77 Kappa,
78 CompressionAvec,
79}
80
81impl NodeFieldKey {
82 fn as_str(self) -> &'static str {
83 match self {
84 NodeFieldKey::Trigger => "trigger",
85 NodeFieldKey::ResponseFormat => "response_format",
86 NodeFieldKey::OriginSession => "origin_session",
87 NodeFieldKey::CompressionDepth => "compression_depth",
88 NodeFieldKey::ParentNode => "parent_node",
89 NodeFieldKey::Prime => "prime",
90 NodeFieldKey::AttractorConfig => "attractor_config",
91 NodeFieldKey::ContextSummary => "context_summary",
92 NodeFieldKey::RelevantTier => "relevant_tier",
93 NodeFieldKey::RetrievalBudget => "retrieval_budget",
94 NodeFieldKey::Timestamp => "timestamp",
95 NodeFieldKey::Tier => "tier",
96 NodeFieldKey::SessionId => "session_id",
97 NodeFieldKey::UserAvec => "user_avec",
98 NodeFieldKey::ModelAvec => "model_avec",
99 NodeFieldKey::Stability => "stability",
100 NodeFieldKey::Friction => "friction",
101 NodeFieldKey::Logic => "logic",
102 NodeFieldKey::Autonomy => "autonomy",
103 NodeFieldKey::Psi => "psi",
104 NodeFieldKey::Rho => "rho",
105 NodeFieldKey::Kappa => "kappa",
106 NodeFieldKey::CompressionAvec => "compression_avec",
107 }
108 }
109}
110
111const PROVENANCE_REQUIRED_KEYS: [NodeFieldKey; 6] = [
112 NodeFieldKey::Trigger,
113 NodeFieldKey::ResponseFormat,
114 NodeFieldKey::OriginSession,
115 NodeFieldKey::CompressionDepth,
116 NodeFieldKey::ParentNode,
117 NodeFieldKey::Prime,
118];
119const PRIME_REQUIRED_KEYS: [NodeFieldKey; 4] = [
120 NodeFieldKey::AttractorConfig,
121 NodeFieldKey::ContextSummary,
122 NodeFieldKey::RelevantTier,
123 NodeFieldKey::RetrievalBudget,
124];
125const ATTRACTOR_REQUIRED_KEYS: [NodeFieldKey; 4] = [
126 NodeFieldKey::Stability,
127 NodeFieldKey::Friction,
128 NodeFieldKey::Logic,
129 NodeFieldKey::Autonomy,
130];
131const ENVELOPE_REQUIRED_KEYS: [NodeFieldKey; 5] = [
132 NodeFieldKey::Timestamp,
133 NodeFieldKey::Tier,
134 NodeFieldKey::SessionId,
135 NodeFieldKey::UserAvec,
136 NodeFieldKey::ModelAvec,
137];
138const AVEC_REQUIRED_KEYS: [NodeFieldKey; 5] = [
139 NodeFieldKey::Stability,
140 NodeFieldKey::Friction,
141 NodeFieldKey::Logic,
142 NodeFieldKey::Autonomy,
143 NodeFieldKey::Psi,
144];
145const METRICS_REQUIRED_KEYS: [NodeFieldKey; 4] = [
146 NodeFieldKey::Rho,
147 NodeFieldKey::Kappa,
148 NodeFieldKey::Psi,
149 NodeFieldKey::CompressionAvec,
150];
151
152trait SpecEnumValue {
153 fn parse_token(input: &str) -> Option<Self>
154 where
155 Self: Sized;
156 fn variants() -> &'static [&'static str];
157}
158
159#[derive(Debug, Clone, Copy)]
160enum TriggerValue {
161 Scheduled,
162 Threshold,
163 Resonance,
164 Seed,
165 Manual,
166}
167
168impl SpecEnumValue for TriggerValue {
169 fn parse_token(input: &str) -> Option<Self> {
170 match input.to_ascii_lowercase().as_str() {
171 "scheduled" => Some(Self::Scheduled),
172 "threshold" => Some(Self::Threshold),
173 "resonance" => Some(Self::Resonance),
174 "seed" => Some(Self::Seed),
175 "manual" => Some(Self::Manual),
176 _ => None,
177 }
178 }
179
180 fn variants() -> &'static [&'static str] {
181 &["scheduled", "threshold", "resonance", "seed", "manual"]
182 }
183}
184
185#[derive(Debug, Clone, Copy)]
186enum ResponseFormatValue {
187 TemporalNode,
188 NaturalLanguage,
189 Hybrid,
190}
191
192impl SpecEnumValue for ResponseFormatValue {
193 fn parse_token(input: &str) -> Option<Self> {
194 match input.to_ascii_lowercase().as_str() {
195 "temporal_node" => Some(Self::TemporalNode),
196 "natural_language" => Some(Self::NaturalLanguage),
197 "hybrid" => Some(Self::Hybrid),
198 _ => None,
199 }
200 }
201
202 fn variants() -> &'static [&'static str] {
203 &["temporal_node", "natural_language", "hybrid"]
204 }
205}
206
207#[derive(Debug, Clone, Copy)]
208enum TierValue {
209 Raw,
210 Daily,
211 Weekly,
212 Monthly,
213 Quarterly,
214 Yearly,
215}
216
217impl SpecEnumValue for TierValue {
218 fn parse_token(input: &str) -> Option<Self> {
219 match input.to_ascii_lowercase().as_str() {
220 "raw" => Some(Self::Raw),
221 "daily" => Some(Self::Daily),
222 "weekly" => Some(Self::Weekly),
223 "monthly" => Some(Self::Monthly),
224 "quarterly" => Some(Self::Quarterly),
225 "yearly" => Some(Self::Yearly),
226 _ => None,
227 }
228 }
229
230 fn variants() -> &'static [&'static str] {
231 &["raw", "daily", "weekly", "monthly", "quarterly", "yearly"]
232 }
233}
234
235#[derive(Debug, Default, Clone, Copy)]
236pub struct SttpNodeParser {
237 profile: ParseProfile,
238}
239
240impl SttpNodeParser {
241 pub fn new() -> Self {
242 Self {
243 profile: ParseProfile::Tolerant,
244 }
245 }
246
247 pub fn with_profile(profile: ParseProfile) -> Self {
248 Self { profile }
249 }
250
251 pub fn try_parse(&self, raw: &str, session_id: &str) -> ParseResult {
252 self.try_parse_with_profile(raw, session_id, self.profile)
253 }
254
255 pub fn try_parse_strict(&self, raw: &str, session_id: &str) -> ParseResult {
256 self.try_parse_with_profile(raw, session_id, ParseProfile::Strict)
257 }
258
259 pub fn try_parse_strict_typed_ir(&self, raw: &str, session_id: &str) -> ParseResult {
260 self.try_parse_with_profile(raw, session_id, ParseProfile::StrictTypedIr)
261 }
262
263 pub fn try_parse_tolerant(&self, raw: &str, session_id: &str) -> ParseResult {
264 self.try_parse_with_profile(raw, session_id, ParseProfile::Tolerant)
265 }
266
267 pub fn try_parse_with_profile(
268 &self,
269 raw: &str,
270 session_id: &str,
271 profile: ParseProfile,
272 ) -> ParseResult {
273 let layered = SttpLayerStateMachine::parse(raw);
274 let provenance = layered.provenance.unwrap_or(raw);
275 let envelope = layered.envelope.unwrap_or(raw);
276 let content = layered.content.unwrap_or(raw);
277 let metrics = layered.metrics.unwrap_or(raw);
278 let mut strict_valid = layered.strict_spine
279 && layered.provenance.is_some()
280 && layered.envelope.is_some()
281 && layered.content.is_some()
282 && layered.metrics.is_some();
283
284 let mut diagnostics = to_structured_diagnostics(&layered.diagnostics);
285 let canonical_ast = Some(CanonicalAst {
286 provenance: layered
287 .provenance
288 .zip(layered.provenance_span)
289 .map(|(source, span)| CanonicalAstLayer {
290 source: source.to_string(),
291 span: to_parse_span(span),
292 }),
293 envelope: layered
294 .envelope
295 .zip(layered.envelope_span)
296 .map(|(source, span)| CanonicalAstLayer {
297 source: source.to_string(),
298 span: to_parse_span(span),
299 }),
300 content: layered
301 .content
302 .zip(layered.content_span)
303 .map(|(source, span)| CanonicalAstLayer {
304 source: source.to_string(),
305 span: to_parse_span(span),
306 }),
307 metrics: layered
308 .metrics
309 .zip(layered.metrics_span)
310 .map(|(source, span)| CanonicalAstLayer {
311 source: source.to_string(),
312 span: to_parse_span(span),
313 }),
314 strict_spine: layered.strict_spine,
315 profile,
316 });
317
318 if matches!(layered.state, ParserState::Error) {
319 diagnostics.push(ParseDiagnostic {
320 code: "STTP_PARSE_LAYER_ERROR".to_string(),
321 message: "unable to identify any STTP layers".to_string(),
322 severity: ParseDiagnosticSeverity::Fatal,
323 strict_impact: true,
324 span: None,
325 });
326
327 return ParseResult::fail_with_metadata(
328 "unable to identify any STTP layers",
329 profile,
330 diagnostics,
331 canonical_ast,
332 );
333 }
334
335 if requires_strict_spine(profile) && !strict_valid {
336 diagnostics.push(ParseDiagnostic {
337 code: "STTP_STRICT_PROFILE_VIOLATION".to_string(),
338 message: "strict profile requires full layer spine provenance->envelope->content->metrics".to_string(),
339 severity: ParseDiagnosticSeverity::Error,
340 strict_impact: true,
341 span: None,
342 });
343
344 return ParseResult::fail_with_metadata(
345 "strict profile violation: missing or out-of-order layers",
346 profile,
347 diagnostics,
348 canonical_ast,
349 );
350 }
351
352 let content_diagnostics = validate_content_schema(raw, content, layered.content_span);
353 if !content_diagnostics.is_empty() {
354 strict_valid = false;
355 for diag in content_diagnostics {
356 diagnostics.push(diag);
357 }
358
359 if requires_strict_spine(profile) {
360 return ParseResult::fail_with_metadata(
361 "strict profile violation: content schema requires field_name(.confidence): value",
362 profile,
363 diagnostics,
364 canonical_ast,
365 );
366 }
367 }
368
369 if requires_typed_ir_properties(profile) {
370 let strict_property_diagnostics = validate_strict_required_properties(
371 provenance,
372 envelope,
373 metrics,
374 layered.provenance_span,
375 layered.envelope_span,
376 layered.metrics_span,
377 );
378 if !strict_property_diagnostics.is_empty() {
379 diagnostics.extend(strict_property_diagnostics);
380
381 return ParseResult::fail_with_metadata(
382 "strict profile violation: required typed-ir properties missing or invalid",
383 profile,
384 diagnostics,
385 canonical_ast,
386 );
387 }
388 }
389
390 let user_avec = parse_avec_block(envelope, AVEC_USER_KEY)
391 .or_else(|| parse_avec_block(raw, AVEC_USER_KEY))
392 .unwrap_or_else(AvecState::zero);
393
394 let model_avec = parse_avec_block(envelope, AVEC_MODEL_KEY)
395 .or_else(|| parse_avec_block(raw, AVEC_MODEL_KEY))
396 .unwrap_or_else(AvecState::zero);
397
398 let compression_avec = parse_avec_block(metrics, AVEC_COMPRESSION_KEY)
399 .or_else(|| parse_avec_block(raw, AVEC_COMPRESSION_KEY))
400 .unwrap_or_else(AvecState::zero);
401
402 let node = SttpNode {
403 raw: raw.to_string(),
404 session_id: session_id.to_string(),
405 tier: parse_tier(envelope).unwrap_or_default(),
406 timestamp: parse_timestamp(envelope).unwrap_or_else(Utc::now),
407 compression_depth: parse_i32_key(provenance, NodeFieldKey::CompressionDepth)
408 .unwrap_or(0),
409 parent_node_id: parse_parent_node(provenance),
410 sync_key: String::new(),
411 updated_at: Utc::now(),
412 source_metadata: None,
413 context_summary: parse_context_summary(provenance)
414 .or_else(|| parse_context_summary(raw)),
415 embedding: None,
416 embedding_model: None,
417 embedding_dimensions: None,
418 embedded_at: None,
419 user_avec,
420 model_avec,
421 compression_avec: Some(compression_avec),
422 rho: parse_f32_key(metrics, NodeFieldKey::Rho).unwrap_or(0.0),
423 kappa: parse_f32_key(metrics, NodeFieldKey::Kappa).unwrap_or(0.0),
424 psi: parse_f32_key(metrics, NodeFieldKey::Psi).unwrap_or(0.0),
425 };
426
427 ParseResult::ok_with_metadata(node, profile, strict_valid, diagnostics, canonical_ast)
428 }
429}
430
431fn requires_strict_spine(profile: ParseProfile) -> bool {
432 matches!(profile, ParseProfile::Strict | ParseProfile::StrictTypedIr)
433}
434
435fn requires_typed_ir_properties(profile: ParseProfile) -> bool {
436 matches!(profile, ParseProfile::StrictTypedIr)
437}
438
439fn validate_content_schema(
440 raw_node: &str,
441 content_layer: &str,
442 layer_span: Option<crate::parsing::lexer::Span>,
443) -> Vec<ParseDiagnostic> {
444 let mut diagnostics = Vec::new();
445
446 let Some(content_object) = extract_first_object(content_layer) else {
447 diagnostics.push(ParseDiagnostic {
448 code: "STTP_CONTENT_SCHEMA_MISSING_OBJECT".to_string(),
449 message: "content layer must contain an object payload".to_string(),
450 severity: ParseDiagnosticSeverity::Error,
451 strict_impact: true,
452 span: layer_span.map(to_parse_span),
453 });
454 return diagnostics;
455 };
456
457 let object_offset = offset_within(content_layer, content_object).unwrap_or(0);
458 validate_object_schema(
459 raw_node,
460 content_layer,
461 content_object,
462 layer_span,
463 object_offset,
464 &mut diagnostics,
465 );
466 diagnostics
467}
468
469fn validate_object_schema(
470 raw_node: &str,
471 content_layer: &str,
472 object_content: &str,
473 layer_span: Option<crate::parsing::lexer::Span>,
474 object_offset: usize,
475 diagnostics: &mut Vec<ParseDiagnostic>,
476) {
477 let pairs = split_top_level_pairs(object_content);
478 if pairs.is_empty() {
479 diagnostics.push(ParseDiagnostic {
480 code: "STTP_CONTENT_SCHEMA_EMPTY_OBJECT".to_string(),
481 message: "content layer must include one or more semantic fields".to_string(),
482 severity: ParseDiagnosticSeverity::Error,
483 strict_impact: true,
484 span: project_content_span(raw_node, content_layer, layer_span, object_offset, 1),
485 });
486 return;
487 }
488
489 for pair in pairs {
490 let Some(colon_idx) = find_top_level_colon(pair.text) else {
491 diagnostics.push(ParseDiagnostic {
492 code: "STTP_CONTENT_SCHEMA_INVALID_PAIR".to_string(),
493 message: format!("content field missing ':' separator: {}", pair.text),
494 severity: ParseDiagnosticSeverity::Error,
495 strict_impact: true,
496 span: project_content_span(
497 raw_node,
498 content_layer,
499 layer_span,
500 object_offset + pair.start,
501 pair.text.len(),
502 ),
503 });
504 continue;
505 };
506
507 let raw_key = pair.text[..colon_idx].trim();
508 let raw_value = pair.text[colon_idx + 1..].trim();
509
510 let Some(signature) = parse_content_key_signature(raw_key) else {
511 diagnostics.push(ParseDiagnostic {
512 code: "STTP_CONTENT_SCHEMA_INVALID_KEY".to_string(),
513 message: format!(
514 "content key must match field_name(.confidence): found '{raw_key}'"
515 ),
516 severity: ParseDiagnosticSeverity::Error,
517 strict_impact: true,
518 span: project_content_span(
519 raw_node,
520 content_layer,
521 layer_span,
522 object_offset + pair.start,
523 raw_key.len(),
524 ),
525 });
526 continue;
527 };
528
529 let confidence = signature.confidence;
530 if !(0.0..=1.0).contains(&confidence) {
531 diagnostics.push(ParseDiagnostic {
532 code: "STTP_CONTENT_SCHEMA_INVALID_CONFIDENCE".to_string(),
533 message: format!(
534 "content confidence must be in [0,1]: found {confidence} for key '{}'",
535 signature.name
536 ),
537 severity: ParseDiagnosticSeverity::Error,
538 strict_impact: true,
539 span: project_content_span(
540 raw_node,
541 content_layer,
542 layer_span,
543 object_offset + pair.start,
544 raw_key.len(),
545 ),
546 });
547 }
548
549 if raw_value.is_empty() {
550 diagnostics.push(ParseDiagnostic {
551 code: "STTP_CONTENT_SCHEMA_MISSING_VALUE".to_string(),
552 message: format!("content value is missing for key '{raw_key}'"),
553 severity: ParseDiagnosticSeverity::Error,
554 strict_impact: true,
555 span: project_content_span(
556 raw_node,
557 content_layer,
558 layer_span,
559 object_offset + pair.start + colon_idx + 1,
560 1,
561 ),
562 });
563 continue;
564 }
565
566 if raw_value.starts_with('{') && raw_value.ends_with('}') {
567 if let Some(inner) = raw_value
568 .strip_prefix('{')
569 .and_then(|v| v.strip_suffix('}'))
570 {
571 let nested_offset = object_offset
572 + pair.start
573 + colon_idx
574 + 1
575 + pair.text[colon_idx + 1..].find('{').unwrap_or(0)
576 + 1;
577 validate_object_schema(
578 raw_node,
579 content_layer,
580 inner,
581 layer_span,
582 nested_offset,
583 diagnostics,
584 );
585 }
586 }
587 }
588}
589
590fn validate_strict_required_properties(
591 provenance: &str,
592 envelope: &str,
593 metrics: &str,
594 provenance_span: Option<crate::parsing::lexer::Span>,
595 envelope_span: Option<crate::parsing::lexer::Span>,
596 metrics_span: Option<crate::parsing::lexer::Span>,
597) -> Vec<ParseDiagnostic> {
598 let mut diagnostics = Vec::new();
599 let prime_object = extract_named_object(provenance, NodeFieldKey::Prime.as_str());
600
601 require_keys(
602 provenance,
603 LayerScope::Provenance,
604 &PROVENANCE_REQUIRED_KEYS,
605 provenance_span,
606 &mut diagnostics,
607 );
608 require_keys(
609 envelope,
610 LayerScope::Envelope,
611 &ENVELOPE_REQUIRED_KEYS,
612 envelope_span,
613 &mut diagnostics,
614 );
615 require_keys(
616 metrics,
617 LayerScope::Metrics,
618 &METRICS_REQUIRED_KEYS,
619 metrics_span,
620 &mut diagnostics,
621 );
622
623 require_named_object_keys(
624 provenance,
625 NodeFieldKey::Prime,
626 ObjectScope::ProvenancePrime,
627 &PRIME_REQUIRED_KEYS,
628 provenance_span,
629 &mut diagnostics,
630 );
631 require_named_object_keys(
632 provenance,
633 NodeFieldKey::AttractorConfig,
634 ObjectScope::ProvenanceAttractorConfig,
635 &ATTRACTOR_REQUIRED_KEYS,
636 provenance_span,
637 &mut diagnostics,
638 );
639 require_named_object_keys(
640 envelope,
641 NodeFieldKey::UserAvec,
642 ObjectScope::EnvelopeUserAvec,
643 &AVEC_REQUIRED_KEYS,
644 envelope_span,
645 &mut diagnostics,
646 );
647 require_named_object_keys(
648 envelope,
649 NodeFieldKey::ModelAvec,
650 ObjectScope::EnvelopeModelAvec,
651 &AVEC_REQUIRED_KEYS,
652 envelope_span,
653 &mut diagnostics,
654 );
655 require_named_object_keys(
656 metrics,
657 NodeFieldKey::CompressionAvec,
658 ObjectScope::MetricsCompressionAvec,
659 &AVEC_REQUIRED_KEYS,
660 metrics_span,
661 &mut diagnostics,
662 );
663
664 require_typed_enum::<TriggerValue>(
665 provenance,
666 NodeFieldKey::Trigger,
667 "provenance.trigger",
668 provenance_span,
669 &mut diagnostics,
670 );
671 require_typed_enum::<ResponseFormatValue>(
672 provenance,
673 NodeFieldKey::ResponseFormat,
674 "provenance.response_format",
675 provenance_span,
676 &mut diagnostics,
677 );
678 require_typed_enum::<TierValue>(
679 envelope,
680 NodeFieldKey::Tier,
681 "envelope.tier",
682 envelope_span,
683 &mut diagnostics,
684 );
685 require_typed_enum_in_optional_object::<TierValue>(
686 prime_object,
687 NodeFieldKey::RelevantTier,
688 "provenance.prime.relevant_tier",
689 provenance_span,
690 &mut diagnostics,
691 );
692
693 require_numeric(
694 provenance,
695 NodeFieldKey::CompressionDepth,
696 "provenance.compression_depth",
697 NumericKind::Integer,
698 provenance_span,
699 &mut diagnostics,
700 );
701 require_numeric_in_optional_object(
702 prime_object,
703 NodeFieldKey::RetrievalBudget,
704 "provenance.prime.retrieval_budget",
705 NumericKind::Integer,
706 provenance_span,
707 &mut diagnostics,
708 );
709 require_numeric(
710 metrics,
711 NodeFieldKey::Rho,
712 "metrics.rho",
713 NumericKind::Float,
714 metrics_span,
715 &mut diagnostics,
716 );
717 require_numeric(
718 metrics,
719 NodeFieldKey::Kappa,
720 "metrics.kappa",
721 NumericKind::Float,
722 metrics_span,
723 &mut diagnostics,
724 );
725 require_numeric(
726 metrics,
727 NodeFieldKey::Psi,
728 "metrics.psi",
729 NumericKind::Float,
730 metrics_span,
731 &mut diagnostics,
732 );
733
734 diagnostics
735}
736
737fn require_typed_enum_in_optional_object<E: SpecEnumValue>(
738 source: Option<&str>,
739 key: NodeFieldKey,
740 path: &str,
741 span: Option<crate::parsing::lexer::Span>,
742 diagnostics: &mut Vec<ParseDiagnostic>,
743) {
744 let Some(source) = source else {
745 diagnostics.push(ParseDiagnostic {
746 code: "STTP_STRICT_MISSING_REQUIRED_OBJECT".to_string(),
747 message: "missing required object 'provenance.prime'".to_string(),
748 severity: ParseDiagnosticSeverity::Error,
749 strict_impact: true,
750 span: span.map(to_parse_span),
751 });
752 return;
753 };
754
755 let Some(value) = parse_scalar_token_in_object(source, key) else {
756 diagnostics.push(ParseDiagnostic {
757 code: "STTP_STRICT_MISSING_REQUIRED_KEY".to_string(),
758 message: format!("missing required enum key '{path}'"),
759 severity: ParseDiagnosticSeverity::Error,
760 strict_impact: true,
761 span: span.map(to_parse_span),
762 });
763 return;
764 };
765
766 if E::parse_token(&value).is_none() {
767 diagnostics.push(ParseDiagnostic {
768 code: "STTP_STRICT_INVALID_ENUM".to_string(),
769 message: format!(
770 "invalid enum value for {path}: '{value}' (expected one of: {})",
771 E::variants().join("|")
772 ),
773 severity: ParseDiagnosticSeverity::Error,
774 strict_impact: true,
775 span: span.map(to_parse_span),
776 });
777 }
778}
779
780fn require_keys(
781 source: &str,
782 layer: LayerScope,
783 keys: &[NodeFieldKey],
784 span: Option<crate::parsing::lexer::Span>,
785 diagnostics: &mut Vec<ParseDiagnostic>,
786) {
787 for key in keys {
788 if !contains_key_in_layer(source, *key) {
789 diagnostics.push(ParseDiagnostic {
790 code: "STTP_STRICT_MISSING_REQUIRED_KEY".to_string(),
791 message: format!(
792 "missing required key '{}' in {} layer",
793 key.as_str(),
794 layer.name()
795 ),
796 severity: ParseDiagnosticSeverity::Error,
797 strict_impact: true,
798 span: span.map(to_parse_span),
799 });
800 }
801 }
802}
803
804fn require_named_object_keys(
805 source: &str,
806 object_key: NodeFieldKey,
807 path: ObjectScope,
808 keys: &[NodeFieldKey],
809 span: Option<crate::parsing::lexer::Span>,
810 diagnostics: &mut Vec<ParseDiagnostic>,
811) {
812 let Some(object) = extract_named_object(source, object_key.as_str()) else {
813 diagnostics.push(ParseDiagnostic {
814 code: "STTP_STRICT_MISSING_REQUIRED_OBJECT".to_string(),
815 message: format!("missing required object '{}'", path.path()),
816 severity: ParseDiagnosticSeverity::Error,
817 strict_impact: true,
818 span: span.map(to_parse_span),
819 });
820 return;
821 };
822
823 for key in keys {
824 if !contains_key_in_object(object, *key) {
825 diagnostics.push(ParseDiagnostic {
826 code: "STTP_STRICT_MISSING_REQUIRED_KEY".to_string(),
827 message: format!("missing required key '{}' in {}", key.as_str(), path.path()),
828 severity: ParseDiagnosticSeverity::Error,
829 strict_impact: true,
830 span: span.map(to_parse_span),
831 });
832 }
833 }
834}
835
836fn require_typed_enum<E: SpecEnumValue>(
837 source: &str,
838 key: NodeFieldKey,
839 path: &str,
840 span: Option<crate::parsing::lexer::Span>,
841 diagnostics: &mut Vec<ParseDiagnostic>,
842) {
843 let Some(value) = parse_scalar_token_in_layer(source, key) else {
844 diagnostics.push(ParseDiagnostic {
845 code: "STTP_STRICT_MISSING_REQUIRED_KEY".to_string(),
846 message: format!("missing required enum key '{path}'"),
847 severity: ParseDiagnosticSeverity::Error,
848 strict_impact: true,
849 span: span.map(to_parse_span),
850 });
851 return;
852 };
853
854 if E::parse_token(&value).is_none() {
855 diagnostics.push(ParseDiagnostic {
856 code: "STTP_STRICT_INVALID_ENUM".to_string(),
857 message: format!(
858 "invalid enum value for {path}: '{value}' (expected one of: {})",
859 E::variants().join("|")
860 ),
861 severity: ParseDiagnosticSeverity::Error,
862 strict_impact: true,
863 span: span.map(to_parse_span),
864 });
865 }
866}
867
868#[derive(Debug, Clone, Copy)]
869enum NumericKind {
870 Integer,
871 Float,
872}
873
874fn require_numeric(
875 source: &str,
876 key: NodeFieldKey,
877 path: &str,
878 kind: NumericKind,
879 span: Option<crate::parsing::lexer::Span>,
880 diagnostics: &mut Vec<ParseDiagnostic>,
881) {
882 let Some(value) = parse_scalar_token_in_layer(source, key) else {
883 diagnostics.push(ParseDiagnostic {
884 code: "STTP_STRICT_MISSING_REQUIRED_KEY".to_string(),
885 message: format!("missing required numeric key '{path}'"),
886 severity: ParseDiagnosticSeverity::Error,
887 strict_impact: true,
888 span: span.map(to_parse_span),
889 });
890 return;
891 };
892
893 let numeric_ok = if matches!(kind, NumericKind::Integer) {
894 value.parse::<i64>().is_ok()
895 } else {
896 value.parse::<f64>().is_ok()
897 };
898
899 if !numeric_ok {
900 diagnostics.push(ParseDiagnostic {
901 code: "STTP_STRICT_INVALID_NUMERIC".to_string(),
902 message: format!("invalid numeric value for {path}: '{value}'"),
903 severity: ParseDiagnosticSeverity::Error,
904 strict_impact: true,
905 span: span.map(to_parse_span),
906 });
907 }
908}
909
910fn require_numeric_in_optional_object(
911 source: Option<&str>,
912 key: NodeFieldKey,
913 path: &str,
914 kind: NumericKind,
915 span: Option<crate::parsing::lexer::Span>,
916 diagnostics: &mut Vec<ParseDiagnostic>,
917) {
918 let Some(source) = source else {
919 diagnostics.push(ParseDiagnostic {
920 code: "STTP_STRICT_MISSING_REQUIRED_OBJECT".to_string(),
921 message: "missing required object 'provenance.prime'".to_string(),
922 severity: ParseDiagnosticSeverity::Error,
923 strict_impact: true,
924 span: span.map(to_parse_span),
925 });
926 return;
927 };
928
929 let Some(value) = parse_scalar_token_in_object(source, key) else {
930 diagnostics.push(ParseDiagnostic {
931 code: "STTP_STRICT_MISSING_REQUIRED_KEY".to_string(),
932 message: format!("missing required numeric key '{path}'"),
933 severity: ParseDiagnosticSeverity::Error,
934 strict_impact: true,
935 span: span.map(to_parse_span),
936 });
937 return;
938 };
939
940 let numeric_ok = if matches!(kind, NumericKind::Integer) {
941 value.parse::<i64>().is_ok()
942 } else {
943 value.parse::<f64>().is_ok()
944 };
945
946 if !numeric_ok {
947 diagnostics.push(ParseDiagnostic {
948 code: "STTP_STRICT_INVALID_NUMERIC".to_string(),
949 message: format!("invalid numeric value for {path}: '{value}'"),
950 severity: ParseDiagnosticSeverity::Error,
951 strict_impact: true,
952 span: span.map(to_parse_span),
953 });
954 }
955}
956
957fn contains_key_in_layer(source: &str, key: NodeFieldKey) -> bool {
958 parse_scalar_token_in_layer(source, key).is_some()
959}
960
961fn contains_key_in_object(source: &str, key: NodeFieldKey) -> bool {
962 parse_scalar_token_in_object(source, key).is_some()
963}
964
965fn parse_scalar_token_in_layer(source: &str, key: NodeFieldKey) -> Option<String> {
966 let object = extract_first_object(source)?;
967 parse_scalar_token_in_object(object, key)
968}
969
970fn parse_scalar_token_in_object(source: &str, key: NodeFieldKey) -> Option<String> {
971 parse_key_value_in_object(source, key.as_str()).map(normalize_scalar_value)
972}
973
974fn parse_key_value_in_object<'a>(object: &'a str, key: &str) -> Option<&'a str> {
975 for pair in split_top_level_pairs(object) {
976 let Some(colon_idx) = find_top_level_colon(pair.text) else {
977 continue;
978 };
979 let raw_key = pair.text[..colon_idx].trim();
980 let normalized = normalize_key(raw_key);
981 if normalized.eq_ignore_ascii_case(key) {
982 return Some(pair.text[colon_idx + 1..].trim());
983 }
984 }
985
986 None
987}
988
989fn normalize_key(raw_key: &str) -> &str {
990 raw_key
991 .strip_prefix('"')
992 .and_then(|v| v.strip_suffix('"'))
993 .unwrap_or(raw_key)
994 .trim()
995}
996
997fn normalize_scalar_value(raw_value: &str) -> String {
998 let trimmed = raw_value.trim();
999 if let Some(unquoted) = trimmed.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
1000 return unquoted.trim().to_string();
1001 }
1002
1003 trimmed.to_string()
1004}
1005
1006fn parse_content_key_signature(raw_key: &str) -> Option<ContentKeySignature<'_>> {
1007 let open = raw_key.find('(')?;
1008 let close = raw_key.rfind(')')?;
1009 if close <= open + 1 {
1010 return None;
1011 }
1012
1013 let name = raw_key[..open].trim();
1014 if name.is_empty() || !is_valid_identifier(name) {
1015 return None;
1016 }
1017
1018 let confidence_text = raw_key[open + 1..close].trim();
1019 let confidence = confidence_text.parse::<f32>().ok()?;
1020
1021 Some(ContentKeySignature { name, confidence })
1022}
1023
1024fn is_valid_identifier(value: &str) -> bool {
1025 let mut chars = value.chars();
1026 let Some(first) = chars.next() else {
1027 return false;
1028 };
1029
1030 if !(first == '_' || first.is_ascii_alphabetic()) {
1031 return false;
1032 }
1033
1034 chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
1035}
1036
1037#[derive(Debug, Clone, Copy)]
1038struct PairSlice<'a> {
1039 text: &'a str,
1040 start: usize,
1041}
1042
1043fn split_top_level_pairs(input: &str) -> Vec<PairSlice<'_>> {
1044 let mut parts = Vec::new();
1045 let mut start = 0usize;
1046 let mut depth_brace = 0usize;
1047 let mut depth_bracket = 0usize;
1048 let mut in_quotes = false;
1049 let mut escape = false;
1050
1051 for (idx, ch) in input.char_indices() {
1052 if in_quotes {
1053 if escape {
1054 escape = false;
1055 continue;
1056 }
1057 if ch == '\\' {
1058 escape = true;
1059 continue;
1060 }
1061 if ch == '"' {
1062 in_quotes = false;
1063 }
1064 continue;
1065 }
1066
1067 match ch {
1068 '"' => in_quotes = true,
1069 '{' => depth_brace += 1,
1070 '}' => depth_brace = depth_brace.saturating_sub(1),
1071 '[' => depth_bracket += 1,
1072 ']' => depth_bracket = depth_bracket.saturating_sub(1),
1073 ',' if depth_brace == 0 && depth_bracket == 0 => {
1074 let part = input[start..idx].trim();
1075 if !part.is_empty() {
1076 let trimmed_start = start + input[start..idx].find(part).unwrap_or(0);
1077 parts.push(PairSlice {
1078 text: part,
1079 start: trimmed_start,
1080 });
1081 }
1082 start = idx + 1;
1083 }
1084 _ => {}
1085 }
1086 }
1087
1088 let tail = input[start..].trim();
1089 if !tail.is_empty() {
1090 let trimmed_start = start + input[start..].find(tail).unwrap_or(0);
1091 parts.push(PairSlice {
1092 text: tail,
1093 start: trimmed_start,
1094 });
1095 }
1096
1097 parts
1098}
1099
1100fn find_top_level_colon(input: &str) -> Option<usize> {
1101 let mut depth_brace = 0usize;
1102 let mut depth_bracket = 0usize;
1103 let mut in_quotes = false;
1104 let mut escape = false;
1105
1106 for (idx, ch) in input.char_indices() {
1107 if in_quotes {
1108 if escape {
1109 escape = false;
1110 continue;
1111 }
1112 if ch == '\\' {
1113 escape = true;
1114 continue;
1115 }
1116 if ch == '"' {
1117 in_quotes = false;
1118 }
1119 continue;
1120 }
1121
1122 match ch {
1123 '"' => in_quotes = true,
1124 '{' => depth_brace += 1,
1125 '}' => depth_brace = depth_brace.saturating_sub(1),
1126 '[' => depth_bracket += 1,
1127 ']' => depth_bracket = depth_bracket.saturating_sub(1),
1128 ':' if depth_brace == 0 && depth_bracket == 0 => return Some(idx),
1129 _ => {}
1130 }
1131 }
1132
1133 None
1134}
1135
1136fn extract_first_object(input: &str) -> Option<&str> {
1137 let start = input.find('{')?;
1138 extract_braced_content(input, start)
1139}
1140
1141fn to_parse_span(span: crate::parsing::lexer::Span) -> ParseSpan {
1142 ParseSpan {
1143 start: span.start,
1144 end: span.end,
1145 line: span.line,
1146 column: span.column,
1147 }
1148}
1149
1150fn offset_within(haystack: &str, needle: &str) -> Option<usize> {
1151 let haystack_start = haystack.as_ptr() as usize;
1152 let needle_start = needle.as_ptr() as usize;
1153 let offset = needle_start.checked_sub(haystack_start)?;
1154 if offset <= haystack.len() {
1155 Some(offset)
1156 } else {
1157 None
1158 }
1159}
1160
1161fn project_content_span(
1162 raw_node: &str,
1163 content_layer: &str,
1164 layer_span: Option<crate::parsing::lexer::Span>,
1165 local_offset_in_object: usize,
1166 len: usize,
1167) -> Option<ParseSpan> {
1168 let layer_span = layer_span?;
1169 let object_offset = extract_first_object(content_layer)
1170 .and_then(|obj| offset_within(content_layer, obj))
1171 .unwrap_or(0);
1172
1173 let start = layer_span.start + object_offset + local_offset_in_object;
1174 let end = start.saturating_add(len.max(1));
1175 let (line, column) = line_col_at(raw_node, start);
1176
1177 Some(ParseSpan {
1178 start,
1179 end,
1180 line,
1181 column,
1182 })
1183}
1184
1185fn line_col_at(raw: &str, target_index: usize) -> (usize, usize) {
1186 let mut line = 1usize;
1187 let mut column = 1usize;
1188 let mut index = 0usize;
1189
1190 for ch in raw.chars() {
1191 if index >= target_index {
1192 break;
1193 }
1194
1195 if ch == '\n' {
1196 line += 1;
1197 column = 1;
1198 } else {
1199 column += 1;
1200 }
1201
1202 index += ch.len_utf8();
1203 }
1204
1205 (line, column)
1206}
1207
1208fn to_structured_diagnostics(codes: &[String]) -> Vec<ParseDiagnostic> {
1209 codes
1210 .iter()
1211 .map(|code| {
1212 let (message, severity, strict_impact) = match code.as_str() {
1213 "non_strict_spine_recovered_tolerantly" => (
1214 "layer order deviates from strict spine; tolerant recovery applied",
1215 ParseDiagnosticSeverity::Warning,
1216 true,
1217 ),
1218 "missing_layer_provenance" => (
1219 "provenance layer marker not found",
1220 ParseDiagnosticSeverity::Error,
1221 true,
1222 ),
1223 "missing_layer_envelope" => (
1224 "envelope layer marker not found",
1225 ParseDiagnosticSeverity::Error,
1226 true,
1227 ),
1228 "missing_layer_content" => (
1229 "content layer marker not found",
1230 ParseDiagnosticSeverity::Warning,
1231 true,
1232 ),
1233 "missing_layer_metrics" => (
1234 "metrics layer marker not found",
1235 ParseDiagnosticSeverity::Error,
1236 true,
1237 ),
1238 _ => (
1239 "parser emitted an unknown diagnostic",
1240 ParseDiagnosticSeverity::Info,
1241 false,
1242 ),
1243 };
1244
1245 ParseDiagnostic {
1246 code: code.clone(),
1247 message: message.to_string(),
1248 severity,
1249 strict_impact,
1250 span: None,
1251 }
1252 })
1253 .collect()
1254}
1255
1256fn parse_avec_block(source: &str, key: &str) -> Option<AvecState> {
1257 let object = extract_named_object(source, key)?;
1258 let stability = parse_f32_key_in_object(object, NodeFieldKey::Stability);
1259 let friction = parse_f32_key_in_object(object, NodeFieldKey::Friction);
1260 let logic = parse_f32_key_in_object(object, NodeFieldKey::Logic);
1261 let autonomy = parse_f32_key_in_object(object, NodeFieldKey::Autonomy);
1262
1263 Some(AvecState {
1264 stability: stability?,
1265 friction: friction?,
1266 logic: logic?,
1267 autonomy: autonomy?,
1268 })
1269}
1270
1271fn parse_timestamp(raw: &str) -> Option<DateTime<Utc>> {
1272 let maybe_ts = parse_scalar_token_in_layer(raw, NodeFieldKey::Timestamp);
1273
1274 if let Some(ts) = maybe_ts {
1275 if let Ok(parsed) = DateTime::parse_from_rfc3339(&ts) {
1276 return Some(parsed.with_timezone(&Utc));
1277 }
1278 }
1279
1280 None
1281}
1282
1283fn parse_tier(raw: &str) -> Option<String> {
1284 parse_scalar_token_in_layer(raw, NodeFieldKey::Tier)
1285}
1286
1287fn parse_parent_node(raw: &str) -> Option<String> {
1288 let value = parse_scalar_token_in_layer(raw, NodeFieldKey::ParentNode)?;
1289 if value.eq_ignore_ascii_case("null") {
1290 return None;
1291 }
1292
1293 if let Some(reference) = value.strip_prefix("ref:") {
1294 return Some(reference.trim().to_string());
1295 }
1296
1297 Some(value)
1298}
1299
1300fn parse_context_summary(raw: &str) -> Option<String> {
1301 let prime = extract_named_object(raw, NodeFieldKey::Prime.as_str())?;
1302 let value = parse_scalar_token_in_object(prime, NodeFieldKey::ContextSummary)?;
1303
1304 if value.is_empty() { None } else { Some(value) }
1305}
1306
1307fn parse_i32_key(source: &str, key: NodeFieldKey) -> Option<i32> {
1308 parse_scalar_token_in_layer(source, key).and_then(|v| v.parse::<i32>().ok())
1309}
1310
1311fn parse_f32_key(source: &str, key: NodeFieldKey) -> Option<f32> {
1312 parse_scalar_token_in_layer(source, key).and_then(|v| v.parse::<f32>().ok())
1313}
1314
1315fn parse_f32_key_in_object(source: &str, key: NodeFieldKey) -> Option<f32> {
1316 parse_scalar_token_in_object(source, key).and_then(|v| v.parse::<f32>().ok())
1317}
1318
1319fn extract_named_object<'a>(source: &'a str, key: &str) -> Option<&'a str> {
1320 let key_index = source.find(key)?;
1321 let after_key = &source[key_index + key.len()..];
1322 let colon_relative = after_key.find(':')?;
1323 let after_colon = &after_key[colon_relative + 1..];
1324
1325 let brace_relative = after_colon.find('{')?;
1326 let absolute_brace_start = key_index + key.len() + colon_relative + 1 + brace_relative;
1327 extract_braced_content(source, absolute_brace_start)
1328}
1329
1330fn extract_braced_content(source: &str, brace_start: usize) -> Option<&str> {
1331 let bytes = source.as_bytes();
1332 if *bytes.get(brace_start)? != b'{' {
1333 return None;
1334 }
1335
1336 let mut depth = 0usize;
1337 for (idx, ch) in source[brace_start..].char_indices() {
1338 match ch {
1339 '{' => depth += 1,
1340 '}' => {
1341 depth = depth.saturating_sub(1);
1342 if depth == 0 {
1343 let content_start = brace_start + 1;
1344 let content_end = brace_start + idx;
1345 return source.get(content_start..content_end);
1346 }
1347 }
1348 _ => {}
1349 }
1350 }
1351
1352 None
1353}
1354
1355#[cfg(test)]
1356mod tests {
1357 use super::*;
1358
1359 #[test]
1360 fn should_parse_avec_with_noncanonical_order() {
1361 let input =
1362 r#"user_avec: { logic: 0.90, stability: 0.81, autonomy: 0.92, friction: 0.11 }"#;
1363 let parsed = parse_avec_block(input, AVEC_USER_KEY).expect("avec should parse");
1364
1365 assert!((parsed.stability - 0.81).abs() < 0.0001);
1366 assert!((parsed.friction - 0.11).abs() < 0.0001);
1367 assert!((parsed.logic - 0.90).abs() < 0.0001);
1368 assert!((parsed.autonomy - 0.92).abs() < 0.0001);
1369 }
1370
1371 #[test]
1372 fn should_extract_nested_object_block() {
1373 let input = r#"compression_avec: { stability: 0.8, friction: 0.2, logic: 0.9, autonomy: 0.7, ext: { kept: 1 } }"#;
1374 let object = extract_named_object(input, AVEC_COMPRESSION_KEY).expect("block should parse");
1375 assert!(object.contains("stability"));
1376 assert!(object.contains("ext: { kept: 1 }"));
1377 }
1378
1379 #[test]
1380 fn should_accept_content_value_with_or_without_quotes() {
1381 let quoted = r#"◈⟨ { topic(.91): \"quoted\" } ⟩"#;
1382 let unquoted = r#"◈⟨ { topic(.91): unquoted_value } ⟩"#;
1383
1384 let quoted_diagnostics = validate_content_schema(quoted, quoted, None);
1385 let unquoted_diagnostics = validate_content_schema(unquoted, unquoted, None);
1386
1387 assert!(quoted_diagnostics.is_empty());
1388 assert!(unquoted_diagnostics.is_empty());
1389 }
1390
1391 #[test]
1392 fn should_reject_content_without_confidence_signature() {
1393 let content = r#"◈⟨ { topic: \"invalid\" } ⟩"#;
1394 let diagnostics = validate_content_schema(content, content, None);
1395
1396 assert!(
1397 diagnostics
1398 .iter()
1399 .any(|d| d.code == "STTP_CONTENT_SCHEMA_INVALID_KEY")
1400 );
1401 }
1402
1403 #[test]
1404 fn should_reject_content_confidence_out_of_range() {
1405 let content = r#"◈⟨ { topic(1.20): \"invalid\" } ⟩"#;
1406 let diagnostics = validate_content_schema(content, content, None);
1407
1408 assert!(
1409 diagnostics
1410 .iter()
1411 .any(|d| d.code == "STTP_CONTENT_SCHEMA_INVALID_CONFIDENCE")
1412 );
1413 }
1414
1415 #[test]
1416 fn should_extract_context_summary_from_prime() {
1417 let parser = SttpNodeParser::new();
1418 let raw = r#"
1419⊕⟨ { trigger: manual, response_format: temporal_node, origin_session: "ctx-test", compression_depth: 1, parent_node: null, prime: { attractor_config: { stability: 0.8, friction: 0.2, logic: 0.9, autonomy: 0.7 }, context_summary: "parser hardening session", relevant_tier: raw, retrieval_budget: 3 } } ⟩
1420⦿⟨ { timestamp: "2026-03-05T06:30:00Z", tier: raw, session_id: "ctx-test", user_avec: { stability: 0.8, friction: 0.2, logic: 0.9, autonomy: 0.7, psi: 2.6 }, model_avec: { stability: 0.8, friction: 0.2, logic: 0.9, autonomy: 0.7, psi: 2.6 } } ⟩
1421◈⟨ { note(.99): "ok" } ⟩
1422⍉⟨ { rho: 0.9, kappa: 0.9, psi: 2.6, compression_avec: { stability: 0.8, friction: 0.2, logic: 0.9, autonomy: 0.7, psi: 2.6 } } ⟩
1423"#;
1424
1425 let parsed = parser.try_parse_tolerant(raw, "ctx-test");
1426 assert!(parsed.success);
1427
1428 let node = parsed.node.expect("parsed node should exist");
1429 assert_eq!(
1430 node.context_summary.as_deref(),
1431 Some("parser hardening session")
1432 );
1433 }
1434
1435 #[test]
1436 fn strict_profile_should_fail_on_missing_layer() {
1437 let parser = SttpNodeParser::new();
1438 let raw = r#"
1439⊕⟨ { trigger: manual, compression_depth: 1 } ⟩
1440⦿⟨ { timestamp: "2026-03-05T06:30:00Z", tier: raw, user_avec: { stability: 0.8, friction: 0.2, logic: 0.9, autonomy: 0.7 }, model_avec: { stability: 0.8, friction: 0.2, logic: 0.9, autonomy: 0.7 } } ⟩
1441⍉⟨ { rho: 0.1, kappa: 0.2, psi: 2.6, compression_avec: { stability: 0.8, friction: 0.2, logic: 0.9, autonomy: 0.7 } } ⟩
1442"#;
1443
1444 let parsed = parser.try_parse_strict(raw, "strict-test");
1445 assert!(!parsed.success);
1446 assert_eq!(parsed.profile, ParseProfile::Strict);
1447 assert!(parsed.canonical_ast.is_some());
1448 assert!(!parsed.diagnostics.is_empty());
1449 }
1450
1451 #[test]
1452 fn tolerant_profile_should_recover_with_diagnostics() {
1453 let parser = SttpNodeParser::new();
1454 let raw = r#"
1455⊕⟨ { trigger: manual, compression_depth: 1 } ⟩
1456⦿⟨ { timestamp: "2026-03-05T06:30:00Z", tier: raw, user_avec: { stability: 0.8, friction: 0.2, logic: 0.9, autonomy: 0.7 }, model_avec: { stability: 0.8, friction: 0.2, logic: 0.9, autonomy: 0.7 } } ⟩
1457⍉⟨ { rho: 0.1, kappa: 0.2, psi: 2.6, compression_avec: { stability: 0.8, friction: 0.2, logic: 0.9, autonomy: 0.7 } } ⟩
1458"#;
1459
1460 let parsed = parser.try_parse_tolerant(raw, "tolerant-test");
1461 assert!(parsed.success);
1462 assert_eq!(parsed.profile, ParseProfile::Tolerant);
1463 assert!(!parsed.strict_valid);
1464 assert!(!parsed.diagnostics.is_empty());
1465 assert!(parsed.canonical_ast.is_some());
1466 }
1467
1468 #[test]
1469 fn strict_profile_should_fail_when_required_property_is_missing() {
1470 let parser = SttpNodeParser::new();
1471 let raw = r#"
1472⊕⟨ { trigger: manual, response_format: temporal_node, compression_depth: 1, parent_node: null, prime: { attractor_config: { stability: 0.8, friction: 0.2, logic: 0.9, autonomy: 0.7 }, context_summary: "missing origin_session", relevant_tier: raw, retrieval_budget: 3 } } ⟩
1473⦿⟨ { timestamp: "2026-03-05T06:30:00Z", tier: raw, session_id: "strict-test", user_avec: { stability: 0.8, friction: 0.2, logic: 0.9, autonomy: 0.7, psi: 2.6 }, model_avec: { stability: 0.8, friction: 0.2, logic: 0.9, autonomy: 0.7, psi: 2.6 } } ⟩
1474◈⟨ { note(.99): "ok" } ⟩
1475⍉⟨ { rho: 0.1, kappa: 0.2, psi: 2.6, compression_avec: { stability: 0.8, friction: 0.2, logic: 0.9, autonomy: 0.7, psi: 2.6 } } ⟩
1476"#;
1477
1478 let parsed = parser.try_parse_strict_typed_ir(raw, "strict-required");
1479 assert!(!parsed.success);
1480 assert_eq!(parsed.profile, ParseProfile::StrictTypedIr);
1481 assert!(parsed.diagnostics.iter().any(|d| {
1482 d.code == "STTP_STRICT_MISSING_REQUIRED_KEY" && d.message.contains("origin_session")
1483 }));
1484 }
1485
1486 #[test]
1487 fn strict_profile_should_fail_on_invalid_enum_value() {
1488 let parser = SttpNodeParser::new();
1489 let raw = r#"
1490⊕⟨ { trigger: manual, response_format: temporal_node, origin_session: "strict-test", compression_depth: 1, parent_node: null, prime: { attractor_config: { stability: 0.8, friction: 0.2, logic: 0.9, autonomy: 0.7 }, context_summary: "bad tier", relevant_tier: badtier, retrieval_budget: 3 } } ⟩
1491⦿⟨ { timestamp: "2026-03-05T06:30:00Z", tier: raw, session_id: "strict-test", user_avec: { stability: 0.8, friction: 0.2, logic: 0.9, autonomy: 0.7, psi: 2.6 }, model_avec: { stability: 0.8, friction: 0.2, logic: 0.9, autonomy: 0.7, psi: 2.6 } } ⟩
1492◈⟨ { note(.99): "ok" } ⟩
1493⍉⟨ { rho: 0.1, kappa: 0.2, psi: 2.6, compression_avec: { stability: 0.8, friction: 0.2, logic: 0.9, autonomy: 0.7, psi: 2.6 } } ⟩
1494"#;
1495
1496 let parsed = parser.try_parse_strict_typed_ir(raw, "strict-enum");
1497 assert!(!parsed.success);
1498 assert_eq!(parsed.profile, ParseProfile::StrictTypedIr);
1499 assert!(parsed.diagnostics.iter().any(|d| {
1500 d.code == "STTP_STRICT_INVALID_ENUM"
1501 || (d.code == "STTP_STRICT_MISSING_REQUIRED_KEY"
1502 && d.message.contains("relevant_tier"))
1503 }));
1504 }
1505
1506 #[test]
1507 fn strict_profile_should_fail_when_content_object_is_empty() {
1508 let parser = SttpNodeParser::new();
1509 let raw = r#"
1510⊕⟨ { trigger: manual, response_format: temporal_node, origin_session: "strict-test", compression_depth: 1, parent_node: null, prime: { attractor_config: { stability: 0.8, friction: 0.2, logic: 0.9, autonomy: 0.7 }, context_summary: "empty content", relevant_tier: raw, retrieval_budget: 3 } } ⟩
1511⦿⟨ { timestamp: "2026-03-05T06:30:00Z", tier: raw, session_id: "strict-test", user_avec: { stability: 0.8, friction: 0.2, logic: 0.9, autonomy: 0.7, psi: 2.6 }, model_avec: { stability: 0.8, friction: 0.2, logic: 0.9, autonomy: 0.7, psi: 2.6 } } ⟩
1512◈⟨ { } ⟩
1513⍉⟨ { rho: 0.1, kappa: 0.2, psi: 2.6, compression_avec: { stability: 0.8, friction: 0.2, logic: 0.9, autonomy: 0.7, psi: 2.6 } } ⟩
1514"#;
1515
1516 let parsed = parser.try_parse_strict_typed_ir(raw, "strict-empty-content");
1517 assert!(!parsed.success);
1518 assert_eq!(parsed.profile, ParseProfile::StrictTypedIr);
1519 assert!(
1520 parsed
1521 .diagnostics
1522 .iter()
1523 .any(|d| d.code == "STTP_CONTENT_SCHEMA_EMPTY_OBJECT")
1524 );
1525 }
1526
1527 #[test]
1528 fn strict_profile_without_typed_ir_should_not_require_origin_session() {
1529 let parser = SttpNodeParser::new();
1530 let raw = r#"
1531⊕⟨ { trigger: manual, response_format: temporal_node, compression_depth: 1, parent_node: null, prime: { attractor_config: { stability: 0.8, friction: 0.2, logic: 0.9, autonomy: 0.7 }, context_summary: "legacy strict", relevant_tier: raw, retrieval_budget: 3 } } ⟩
1532⦿⟨ { timestamp: "2026-03-05T06:30:00Z", tier: raw, session_id: "strict-test", user_avec: { stability: 0.8, friction: 0.2, logic: 0.9, autonomy: 0.7, psi: 2.6 }, model_avec: { stability: 0.8, friction: 0.2, logic: 0.9, autonomy: 0.7, psi: 2.6 } } ⟩
1533◈⟨ { note(.99): "ok" } ⟩
1534⍉⟨ { rho: 0.1, kappa: 0.2, psi: 2.6, compression_avec: { stability: 0.8, friction: 0.2, logic: 0.9, autonomy: 0.7, psi: 2.6 } } ⟩
1535"#;
1536
1537 let parsed = parser.try_parse_strict(raw, "strict-legacy");
1538 assert!(parsed.success);
1539 assert_eq!(parsed.profile, ParseProfile::Strict);
1540 }
1541}