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}