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