Skip to main content

locus_core_rs/parsing/
sttp_node_parser.rs

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}