Skip to main content

constraint_compiler/
lib.rs

1#![cfg_attr(test, allow(clippy::expect_used))]
2
3use forge_memory_bridge::{ImportProjectionRecord, ProjectionImportBatchV3};
4use recursive_kernel_core::{constraint_compiler_operator, ConstraintUnit, OperatorMetadata};
5use schemars::JsonSchema;
6use semantic_memory_forge::{ConstraintSeedKind, ExportRecordSemanticsV3};
7use serde::{Deserialize, Serialize};
8use stack_ids::{ConstraintId, ContentDigest, OracleSliceId, RegionDigestId, RegionId, ScopeKey};
9use std::collections::{BTreeMap, BTreeSet};
10
11fn compiler_operator() -> OperatorMetadata {
12    constraint_compiler_operator()
13}
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
16pub struct CompilerPolicy {
17    pub policy_version: String,
18    pub include_hyperedges: bool,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
22#[serde(rename_all = "snake_case")]
23pub enum ConstraintDegradation {
24    MissingClaimFamily,
25    MissingAssertionGroup,
26    MissingRelationGroup,
27    ThinExport,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
31pub struct InferenceNode {
32    pub node_id: String,
33    pub kind: String,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
37pub struct InferenceHyperedge {
38    pub edge_id: String,
39    pub member_node_ids: Vec<String>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
43pub struct InvalidationCone {
44    pub source_node_id: String,
45    pub affected_node_ids: Vec<String>,
46    pub affected_hyperedge_ids: Vec<String>,
47    pub affected_constraint_ids: Vec<ConstraintId>,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
51pub struct OracleSliceCandidate {
52    pub oracle_slice_id: OracleSliceId,
53    pub node_ids: Vec<String>,
54}
55
56#[derive(
57    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
58)]
59#[serde(rename_all = "snake_case")]
60pub enum GraphSurfaceKind {
61    Storage,
62    Retrieval,
63    Inference,
64    Repair,
65    Control,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
69pub struct CompilationBoundary {
70    pub from_surface: GraphSurfaceKind,
71    pub to_surface: GraphSurfaceKind,
72    pub artifact_families: Vec<String>,
73    pub deterministic: bool,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
77pub struct GraphGeometryManifest {
78    pub surfaces: Vec<GraphSurfaceKind>,
79    pub compilation_boundaries: Vec<CompilationBoundary>,
80    pub no_silent_collapse: bool,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
84pub struct CompiledRegion {
85    pub region_id: RegionId,
86    pub region_digest_id: RegionDigestId,
87    pub node_ids: Vec<String>,
88    pub hyperedge_ids: Vec<String>,
89    pub constraint_ids: Vec<ConstraintId>,
90    pub bounded_default_unit_of_work: bool,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
94pub struct CompileOutput {
95    pub graph_hash: ContentDigest,
96    pub scope_key: ScopeKey,
97    pub geometry_manifest: GraphGeometryManifest,
98    pub nodes: Vec<InferenceNode>,
99    pub hyperedges: Vec<InferenceHyperedge>,
100    pub constraints: Vec<ConstraintUnit>,
101    pub regions: Vec<CompiledRegion>,
102    pub invalidation_cones: Vec<InvalidationCone>,
103    pub degradations: Vec<ConstraintDegradation>,
104    pub oracle_candidates: Vec<OracleSliceCandidate>,
105}
106
107/// Compiles an importer batch into the bounded inference graph consumed by the kernel lane.
108pub fn compile_batch(batch: &ProjectionImportBatchV3, policy: &CompilerPolicy) -> CompileOutput {
109    let compiler = compiler_operator();
110    let mut nodes = BTreeMap::<String, InferenceNode>::new();
111    let mut hyperedge_members = BTreeMap::<String, BTreeSet<String>>::new();
112    let mut hyperedge_kinds = BTreeMap::<String, String>::new();
113    let mut degradations = Vec::new();
114    let mut nuisance_constraints = Vec::new();
115    let auxiliary_records_stay_thin = batch.records.iter().any(|record| {
116        record.semantics.as_ref().is_some_and(|semantics| {
117            semantics.export_confidence_class
118                == semantic_memory_forge::ExportConfidenceClass::ThinExport
119        })
120    });
121
122    for rich_record in &batch.records {
123        if !matches!(
124            rich_record.record,
125            ImportProjectionRecord::ClaimVersion(_) | ImportProjectionRecord::RelationVersion(_)
126        ) {
127            if rich_record.semantics.is_none()
128                && (matches!(rich_record.record, ImportProjectionRecord::Episode(_))
129                    || (auxiliary_records_stay_thin
130                        && matches!(
131                            rich_record.record,
132                            ImportProjectionRecord::EntityAlias(_)
133                                | ImportProjectionRecord::EvidenceRef(_)
134                        )))
135            {
136                degradations.push(ConstraintDegradation::ThinExport);
137            }
138            continue;
139        }
140
141        let node_id = stable_node_id(&rich_record.record);
142        let kind = record_kind(&rich_record.record).to_string();
143        nodes.insert(
144            node_id.clone(),
145            InferenceNode {
146                node_id: node_id.clone(),
147                kind,
148            },
149        );
150
151        match &rich_record.semantics {
152            Some(semantics) => {
153                materialize_nuisance_state(
154                    &mut nodes,
155                    &mut hyperedge_members,
156                    &mut hyperedge_kinds,
157                    &mut nuisance_constraints,
158                    &node_id,
159                    semantics,
160                    policy.include_hyperedges,
161                );
162                match rich_record.record {
163                    ImportProjectionRecord::ClaimVersion(_) => {
164                        if policy.include_hyperedges {
165                            if let Some(group_id) = &semantics.assertion_group_id {
166                                let edge_id = format!("assertion_group:{group_id}");
167                                hyperedge_members
168                                    .entry(edge_id.clone())
169                                    .or_default()
170                                    .insert(node_id.clone());
171                                hyperedge_kinds.entry(edge_id).or_insert_with(|| {
172                                    constraint_seed_name(semantics.constraint_seed_kind.as_ref())
173                                });
174                            } else {
175                                degradations.push(ConstraintDegradation::MissingAssertionGroup);
176                            }
177                        }
178                    }
179                    ImportProjectionRecord::RelationVersion(_) => {
180                        if policy.include_hyperedges {
181                            if let Some(group_id) = &semantics.relation_group_id {
182                                let edge_id = format!("relation_group:{group_id}");
183                                hyperedge_members
184                                    .entry(edge_id.clone())
185                                    .or_default()
186                                    .insert(node_id.clone());
187                                hyperedge_kinds.entry(edge_id).or_insert_with(|| {
188                                    constraint_seed_name(semantics.constraint_seed_kind.as_ref())
189                                });
190                            } else {
191                                degradations.push(ConstraintDegradation::MissingRelationGroup);
192                            }
193                        }
194                    }
195                    ImportProjectionRecord::Episode(_)
196                    | ImportProjectionRecord::EntityAlias(_)
197                    | ImportProjectionRecord::EvidenceRef(_) => {}
198                }
199            }
200            None => {
201                degradations.push(ConstraintDegradation::ThinExport);
202            }
203        }
204
205        if let Some(semantics) = &rich_record.semantics {
206            if semantics.claim_family_id.is_none()
207                && matches!(rich_record.record, ImportProjectionRecord::ClaimVersion(_))
208            {
209                degradations.push(ConstraintDegradation::MissingClaimFamily);
210            }
211            if semantics.relation_group_id.is_none()
212                && matches!(
213                    rich_record.record,
214                    ImportProjectionRecord::RelationVersion(_)
215                )
216            {
217                degradations.push(ConstraintDegradation::MissingRelationGroup);
218            }
219        }
220    }
221
222    let mut nodes: Vec<_> = nodes.into_values().collect();
223    nodes.sort_by(|a, b| a.node_id.cmp(&b.node_id));
224
225    let mut hyperedges = Vec::new();
226    let mut constraints = Vec::new();
227    for (edge_id, members) in hyperedge_members {
228        let mut member_node_ids: Vec<_> = members.into_iter().collect();
229        member_node_ids.sort();
230
231        hyperedges.push(InferenceHyperedge {
232            edge_id: edge_id.clone(),
233            member_node_ids: member_node_ids.clone(),
234        });
235        constraints.push(ConstraintUnit {
236            constraint_id: ConstraintId::new(format!("constraint:{edge_id}")),
237            kind: hyperedge_kinds
238                .get(&edge_id)
239                .cloned()
240                .unwrap_or_else(|| "group".into()),
241            variable_ids: member_node_ids,
242            operator_id: compiler.operator_id.clone(),
243        });
244    }
245    constraints.extend(nuisance_constraints);
246
247    hyperedges.sort_by(|a, b| a.edge_id.cmp(&b.edge_id));
248    constraints.sort_by(|a, b| a.constraint_id.as_str().cmp(b.constraint_id.as_str()));
249    let geometry_manifest = graph_geometry_manifest();
250    let regions = compile_regions(&nodes, &hyperedges, &constraints);
251    let invalidation_cones = build_invalidation_cones(&hyperedges, &constraints);
252    degradations.sort();
253    degradations.dedup();
254
255    let oracle_candidate_node_ids = nodes
256        .iter()
257        .filter(|node| node.kind != "nuisance_state")
258        .map(|node| node.node_id.clone())
259        .collect::<Vec<_>>();
260    let oracle_candidates = if degradations.is_empty()
261        && !oracle_candidate_node_ids.is_empty()
262        && oracle_candidate_node_ids.len() <= 8
263    {
264        vec![OracleSliceCandidate {
265            oracle_slice_id: OracleSliceId::new(format!("oracle:{}", batch.source_envelope_id)),
266            node_ids: oracle_candidate_node_ids,
267        }]
268    } else {
269        vec![]
270    };
271
272    let graph_hash = match ContentDigest::compute_json(&(
273        policy,
274        &batch.scope_key,
275        &geometry_manifest,
276        &nodes,
277        &hyperedges,
278        &constraints,
279        &regions,
280        &invalidation_cones,
281        &degradations,
282    )) {
283        Ok(digest) => digest,
284        Err(err) => ContentDigest::compute_str(&format!(
285            "constraint-compiler.graph-hash-serialization-error:{err}"
286        )),
287    };
288
289    CompileOutput {
290        graph_hash,
291        scope_key: batch.scope_key.clone(),
292        geometry_manifest,
293        nodes,
294        hyperedges,
295        constraints,
296        regions,
297        invalidation_cones,
298        degradations,
299        oracle_candidates,
300    }
301}
302
303fn graph_geometry_manifest() -> GraphGeometryManifest {
304    GraphGeometryManifest {
305        surfaces: vec![
306            GraphSurfaceKind::Storage,
307            GraphSurfaceKind::Retrieval,
308            GraphSurfaceKind::Inference,
309            GraphSurfaceKind::Repair,
310            GraphSurfaceKind::Control,
311        ],
312        compilation_boundaries: vec![
313            CompilationBoundary {
314                from_surface: GraphSurfaceKind::Storage,
315                to_surface: GraphSurfaceKind::Retrieval,
316                artifact_families: vec!["candidate_expansion".into(), "query_scope".into()],
317                deterministic: true,
318            },
319            CompilationBoundary {
320                from_surface: GraphSurfaceKind::Retrieval,
321                to_surface: GraphSurfaceKind::Inference,
322                artifact_families: vec!["candidate_claims".into(), "evidence_refs".into()],
323                deterministic: true,
324            },
325            CompilationBoundary {
326                from_surface: GraphSurfaceKind::Inference,
327                to_surface: GraphSurfaceKind::Repair,
328                artifact_families: vec![
329                    "syndrome".into(),
330                    "witness".into(),
331                    "invalidation_manifest".into(),
332                ],
333                deterministic: true,
334            },
335            CompilationBoundary {
336                from_surface: GraphSurfaceKind::Control,
337                to_surface: GraphSurfaceKind::Inference,
338                artifact_families: vec!["region_budget".into(), "execution_budget".into()],
339                deterministic: true,
340            },
341            CompilationBoundary {
342                from_surface: GraphSurfaceKind::Inference,
343                to_surface: GraphSurfaceKind::Control,
344                artifact_families: vec!["control_receipt".into(), "residual".into()],
345                deterministic: true,
346            },
347        ],
348        no_silent_collapse: true,
349    }
350}
351
352fn compile_regions(
353    nodes: &[InferenceNode],
354    hyperedges: &[InferenceHyperedge],
355    constraints: &[ConstraintUnit],
356) -> Vec<CompiledRegion> {
357    let mut node_neighbors = BTreeMap::<String, BTreeSet<String>>::new();
358    let mut edges_by_node = BTreeMap::<String, BTreeSet<String>>::new();
359
360    for node in nodes {
361        node_neighbors.entry(node.node_id.clone()).or_default();
362        edges_by_node.entry(node.node_id.clone()).or_default();
363    }
364
365    for hyperedge in hyperedges {
366        for node_id in &hyperedge.member_node_ids {
367            edges_by_node
368                .entry(node_id.clone())
369                .or_default()
370                .insert(hyperedge.edge_id.clone());
371            for peer in &hyperedge.member_node_ids {
372                if peer != node_id {
373                    node_neighbors
374                        .entry(node_id.clone())
375                        .or_default()
376                        .insert(peer.clone());
377                }
378            }
379        }
380    }
381
382    let mut visited = BTreeSet::new();
383    let constraint_lookup = constraints
384        .iter()
385        .map(|constraint| {
386            (
387                constraint
388                    .constraint_id
389                    .as_str()
390                    .strip_prefix("constraint:")
391                    .unwrap_or(constraint.constraint_id.as_str())
392                    .to_string(),
393                constraint.constraint_id.clone(),
394            )
395        })
396        .collect::<BTreeMap<_, _>>();
397    let mut regions = Vec::new();
398
399    for node in nodes {
400        if !visited.insert(node.node_id.clone()) {
401            continue;
402        }
403
404        let mut queue = vec![node.node_id.clone()];
405        let mut node_ids = BTreeSet::new();
406        let mut hyperedge_ids = BTreeSet::new();
407        let mut constraint_ids = BTreeSet::new();
408
409        while let Some(current) = queue.pop() {
410            node_ids.insert(current.clone());
411
412            if let Some(edge_ids) = edges_by_node.get(&current) {
413                for edge_id in edge_ids {
414                    hyperedge_ids.insert(edge_id.clone());
415                    if let Some(constraint_id) = constraint_lookup.get(edge_id) {
416                        constraint_ids.insert(constraint_id.clone());
417                    }
418                }
419            }
420
421            if let Some(neighbors) = node_neighbors.get(&current) {
422                for neighbor in neighbors {
423                    if visited.insert(neighbor.clone()) {
424                        queue.push(neighbor.clone());
425                    }
426                }
427            }
428        }
429
430        let node_ids = node_ids.into_iter().collect::<Vec<_>>();
431        let hyperedge_ids = hyperedge_ids.into_iter().collect::<Vec<_>>();
432        let constraint_ids = constraint_ids.into_iter().collect::<Vec<_>>();
433        let region_digest =
434            match ContentDigest::compute_json(&(&node_ids, &hyperedge_ids, &constraint_ids)) {
435                Ok(digest) => digest,
436                Err(err) => ContentDigest::compute_str(&format!(
437                    "constraint-compiler.region-digest-serialization-error:{err}"
438                )),
439            };
440        regions.push(CompiledRegion {
441            region_id: RegionId::new(format!("region:{}", region_digest.hex())),
442            region_digest_id: RegionDigestId::new(format!("region-digest:{}", region_digest.hex())),
443            node_ids,
444            hyperedge_ids,
445            constraint_ids,
446            bounded_default_unit_of_work: true,
447        });
448    }
449
450    regions.sort_by(|a, b| a.region_id.as_str().cmp(b.region_id.as_str()));
451    regions
452}
453
454fn constraint_seed_name(kind: Option<&ConstraintSeedKind>) -> String {
455    match kind {
456        Some(ConstraintSeedKind::Hyperedge) => "hyperedge".into(),
457        Some(ConstraintSeedKind::MutualExclusion) => "mutual_exclusion".into(),
458        Some(ConstraintSeedKind::TemporalCoherence) => "temporal_coherence".into(),
459        Some(ConstraintSeedKind::CausalRefutation) => "causal_refutation".into(),
460        Some(ConstraintSeedKind::NuisanceDisclosure) => "nuisance_disclosure".into(),
461        None => "group".into(),
462    }
463}
464
465fn materialize_nuisance_state(
466    nodes: &mut BTreeMap<String, InferenceNode>,
467    hyperedge_members: &mut BTreeMap<String, BTreeSet<String>>,
468    hyperedge_kinds: &mut BTreeMap<String, String>,
469    nuisance_constraints: &mut Vec<ConstraintUnit>,
470    source_node_id: &str,
471    semantics: &ExportRecordSemanticsV3,
472    include_hyperedges: bool,
473) {
474    let Some(nuisance_key) = nuisance_node_id(semantics) else {
475        return;
476    };
477
478    nodes
479        .entry(nuisance_key.clone())
480        .or_insert_with(|| InferenceNode {
481            node_id: nuisance_key.clone(),
482            kind: "nuisance_state".into(),
483        });
484
485    let edge_id = format!("nuisance_edge:{source_node_id}:{nuisance_key}");
486    if include_hyperedges {
487        hyperedge_members
488            .entry(edge_id.clone())
489            .or_default()
490            .extend([source_node_id.to_string(), nuisance_key.clone()]);
491        hyperedge_kinds
492            .entry(edge_id.clone())
493            .or_insert_with(|| "nuisance_disclosure".into());
494        return;
495    }
496
497    nuisance_constraints.push(ConstraintUnit {
498        constraint_id: ConstraintId::new(format!("constraint:{edge_id}")),
499        kind: "nuisance_disclosure".into(),
500        variable_ids: vec![source_node_id.to_string(), nuisance_key],
501        operator_id: compiler_operator().operator_id,
502    });
503}
504
505fn nuisance_node_id(semantics: &ExportRecordSemanticsV3) -> Option<String> {
506    fn hash_sorted_values(builder: &mut blake3::Hasher, values: &[String]) {
507        let mut normalized = values.to_vec();
508        normalized.sort();
509        normalized.dedup();
510        for value in normalized {
511            builder.update(value.as_bytes());
512        }
513    }
514
515    if let Some(version) = semantics.comparability_snapshot_version.as_ref() {
516        return Some(format!("nuisance:comparability:{version}"));
517    }
518
519    let snapshot = semantics.nuisance_snapshot.as_ref()?;
520    let mut builder = blake3::Hasher::new();
521    if let Some(value) = snapshot.environment_fingerprint.as_deref() {
522        builder.update(value.as_bytes());
523    }
524    if let Some(value) = snapshot.toolchain_version.as_deref() {
525        builder.update(value.as_bytes());
526    }
527    if let Some(value) = snapshot.dependency_set_hash.as_deref() {
528        builder.update(value.as_bytes());
529    }
530    hash_sorted_values(&mut builder, &snapshot.scope_mismatch_markers);
531    hash_sorted_values(&mut builder, &snapshot.measurement_notes);
532    hash_sorted_values(&mut builder, &snapshot.selection_bias_markers);
533    Some(format!("nuisance:snapshot:{}", builder.finalize().to_hex()))
534}
535
536fn build_invalidation_cones(
537    hyperedges: &[InferenceHyperedge],
538    constraints: &[ConstraintUnit],
539) -> Vec<InvalidationCone> {
540    let mut edges_by_node = BTreeMap::<String, BTreeSet<String>>::new();
541    for hyperedge in hyperedges {
542        for node_id in &hyperedge.member_node_ids {
543            edges_by_node
544                .entry(node_id.clone())
545                .or_default()
546                .insert(hyperedge.edge_id.clone());
547        }
548    }
549
550    let constraints_by_edge: BTreeMap<_, _> = constraints
551        .iter()
552        .map(|constraint| {
553            (
554                constraint
555                    .constraint_id
556                    .as_str()
557                    .strip_prefix("constraint:")
558                    .unwrap_or(constraint.constraint_id.as_str())
559                    .to_string(),
560                constraint.constraint_id.clone(),
561            )
562        })
563        .collect();
564
565    let mut cones = Vec::new();
566    for (source_node_id, edge_ids) in edges_by_node {
567        let mut affected_node_ids = BTreeSet::new();
568        let mut affected_constraint_ids = BTreeSet::new();
569
570        for edge_id in &edge_ids {
571            if let Some(hyperedge) = hyperedges.iter().find(|edge| edge.edge_id == *edge_id) {
572                affected_node_ids.extend(hyperedge.member_node_ids.iter().cloned());
573            }
574            if let Some(constraint_id) = constraints_by_edge.get(edge_id) {
575                affected_constraint_ids.insert(constraint_id.clone());
576            }
577        }
578
579        cones.push(InvalidationCone {
580            source_node_id,
581            affected_node_ids: affected_node_ids.into_iter().collect(),
582            affected_hyperedge_ids: edge_ids.into_iter().collect(),
583            affected_constraint_ids: affected_constraint_ids.into_iter().collect(),
584        });
585    }
586    cones.sort_by(|a, b| a.source_node_id.cmp(&b.source_node_id));
587    cones
588}
589
590fn stable_node_id(record: &ImportProjectionRecord) -> String {
591    match record {
592        ImportProjectionRecord::ClaimVersion(claim) => claim.claim_version_id.to_string(),
593        ImportProjectionRecord::RelationVersion(relation) => {
594            relation.relation_version_id.to_string()
595        }
596        ImportProjectionRecord::Episode(episode) => episode.episode_id.to_string(),
597        ImportProjectionRecord::EntityAlias(alias) => {
598            format!("alias:{}:{}", alias.canonical_entity_id, alias.alias_text)
599        }
600        ImportProjectionRecord::EvidenceRef(evidence) => {
601            format!("evidence:{}:{}", evidence.claim_id, evidence.fetch_handle)
602        }
603    }
604}
605
606fn record_kind(record: &ImportProjectionRecord) -> &'static str {
607    match record {
608        ImportProjectionRecord::ClaimVersion(_) => "claim_version",
609        ImportProjectionRecord::RelationVersion(_) => "relation_version",
610        ImportProjectionRecord::Episode(_) => "episode",
611        ImportProjectionRecord::EntityAlias(_) => "entity_alias",
612        ImportProjectionRecord::EvidenceRef(_) => "evidence_ref",
613    }
614}
615
616#[cfg(test)]
617#[path = "lib_tests.rs"]
618mod tests;