Skip to main content

profile_runtime/
compose.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3type ObligationEntryMapValue = (Vec<String>, Option<i64>, Option<String>, bool);
4type ObligationEntryMapKey = (String, String);
5
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use stack_ids::{
9    CompiledObligationSetId, CompositionConflictSetId, CompositionReceiptId, ContentDigest,
10    EffectiveConstitutionId, PolicyImpactDiffId,
11};
12use thiserror::Error;
13
14use crate::{
15    applicability::ApplicabilityContextV1,
16    constitution::{
17        BlockEntryV1, CompiledObligationEntryV1, CompiledObligationSetV1, CompositionConflictSetV1,
18        CompositionConflictV1, CompositionReceiptV1, ConstitutionModeV1, EffectiveConstitutionV1,
19        PolicyImpactDiffV1, COMPILED_OBLIGATION_SET_V1_SCHEMA, COMPOSITION_CONFLICT_SET_V1_SCHEMA,
20        COMPOSITION_RECEIPT_V1_SCHEMA, EFFECTIVE_CONSTITUTION_V1_SCHEMA,
21        POLICY_IMPACT_DIFF_V1_SCHEMA,
22    },
23    exception::ProfileExceptionBundleV1,
24    profile_set::ProfileSetV1,
25    rules::{
26        CompiledObligationKindV1, CompositionRuleSetV1, FoldClassV1, REFERENCE_EVALUATOR_VERSION_V1,
27    },
28};
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
31pub struct ObligationContributionV1 {
32    pub obligation_family: String,
33    pub obligation_key: String,
34    pub output_kind: CompiledObligationKindV1,
35    pub fold_class: FoldClassV1,
36    #[serde(default, skip_serializing_if = "Vec::is_empty")]
37    pub string_values: Vec<String>,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub numeric_value: Option<i64>,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub expiry_at: Option<String>,
42    pub blocking: bool,
43    pub source_profile_ref: String,
44    #[serde(default, skip_serializing_if = "Vec::is_empty")]
45    pub admissible_exception_classes: Vec<String>,
46    pub explanation: String,
47}
48
49impl ObligationContributionV1 {
50    /// Builds a simple contribution without numeric or expiry metadata.
51    pub fn simple(
52        obligation_family: impl Into<String>,
53        obligation_key: impl Into<String>,
54        output_kind: CompiledObligationKindV1,
55        fold_class: FoldClassV1,
56        string_values: Vec<String>,
57        source_profile_ref: impl Into<String>,
58        explanation: impl Into<String>,
59    ) -> Self {
60        Self {
61            obligation_family: obligation_family.into(),
62            obligation_key: obligation_key.into(),
63            output_kind,
64            fold_class,
65            string_values,
66            numeric_value: None,
67            expiry_at: None,
68            blocking: false,
69            source_profile_ref: source_profile_ref.into(),
70            admissible_exception_classes: Vec::new(),
71            explanation: explanation.into(),
72        }
73    }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct CompositionOutcomeV1 {
78    pub receipt: CompositionReceiptV1,
79    pub effective_constitution: EffectiveConstitutionV1,
80    pub compiled_obligation_set: CompiledObligationSetV1,
81    pub conflict_set: Option<CompositionConflictSetV1>,
82}
83
84#[derive(Debug, Error)]
85pub enum CompositionError {
86    #[error("profile set does not match applicability context")]
87    ApplicabilityMismatch,
88    #[error("unsupported fold configuration for family '{family}' key '{key}'")]
89    UnsupportedFold { family: String, key: String },
90    #[error("digest computation failed: {reason}")]
91    DigestFailed { reason: String },
92}
93
94impl CompositionError {
95    /// Returns a stable string discriminant for this error kind.
96    pub fn kind(&self) -> &'static str {
97        match self {
98            Self::ApplicabilityMismatch => "applicability_mismatch",
99            Self::UnsupportedFold { .. } => "unsupported_fold",
100            Self::DigestFailed { .. } => "digest_failed",
101        }
102    }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
106struct GroupKey {
107    family: String,
108    key: String,
109    output_kind: CompiledObligationKindV1,
110    fold_class: FoldClassV1,
111}
112
113/// Folds profile contributions and admitted exceptions into the effective constitution outputs.
114pub fn compose_profile_runtime(
115    context: &ApplicabilityContextV1,
116    profile_set: &ProfileSetV1,
117    rule_set: &CompositionRuleSetV1,
118    contributions: &[ObligationContributionV1],
119    exceptions: &[ProfileExceptionBundleV1],
120    generated_at: impl Into<String>,
121) -> Result<CompositionOutcomeV1, CompositionError> {
122    if profile_set.applicability_context_ref != context.applicability_context_id {
123        return Err(CompositionError::ApplicabilityMismatch);
124    }
125
126    let generated_at = generated_at.into();
127    let active_exceptions = active_exceptions(context, exceptions);
128
129    let mut grouped: BTreeMap<GroupKey, Vec<&ObligationContributionV1>> = BTreeMap::new();
130    for contribution in contributions {
131        grouped
132            .entry(GroupKey {
133                family: contribution.obligation_family.clone(),
134                key: contribution.obligation_key.clone(),
135                output_kind: contribution.output_kind,
136                fold_class: contribution.fold_class,
137            })
138            .or_default()
139            .push(contribution);
140    }
141
142    let mut entries = Vec::new();
143    let mut block_entries = Vec::new();
144    let mut conflicts = Vec::new();
145
146    for (group_key, group) in grouped {
147        let matched_exception_ids = matched_exception_ids(&group, &active_exceptions, rule_set);
148        let matched_exception_classes =
149            matched_exception_classes(&group, &active_exceptions, rule_set);
150        let blocking_contributions: Vec<_> = group
151            .iter()
152            .copied()
153            .filter(|entry| entry.blocking)
154            .collect();
155
156        match group_key.fold_class {
157            FoldClassV1::Union => {
158                let string_values = union_values(&group);
159                let numeric_value = single_numeric_if_all_equal(&group);
160                let expiry_at = earliest_expiry_value(&group);
161                if string_values.is_empty() && numeric_value.is_none() && expiry_at.is_none() {
162                    continue;
163                }
164                entries.push(compiled_entry_from_group(
165                    &group_key,
166                    &group,
167                    string_values,
168                    numeric_value,
169                    expiry_at,
170                    !blocking_contributions.is_empty(),
171                    matched_exception_ids,
172                ));
173            }
174            FoldClassV1::Intersection => {
175                let intersection = intersect_values(&group);
176                if intersection.is_empty()
177                    && group.iter().any(|entry| !entry.string_values.is_empty())
178                {
179                    if matched_exception_classes.is_empty() {
180                        conflicts.push(conflict_from_group(
181                            &group_key,
182                            &group,
183                            rule_set,
184                            "empty_intersection",
185                        ));
186                    } else {
187                        entries.push(compiled_entry_from_group(
188                            &group_key,
189                            &group,
190                            union_values(&group),
191                            single_numeric_if_all_equal(&group),
192                            earliest_expiry_value(&group),
193                            false,
194                            matched_exception_ids,
195                        ));
196                    }
197                } else if !intersection.is_empty() {
198                    entries.push(compiled_entry_from_group(
199                        &group_key,
200                        &group,
201                        intersection,
202                        single_numeric_if_all_equal(&group),
203                        earliest_expiry_value(&group),
204                        false,
205                        matched_exception_ids,
206                    ));
207                }
208            }
209            FoldClassV1::MinOfMaxima => {
210                if let Some(value) = min_numeric(&group) {
211                    entries.push(compiled_entry_from_group(
212                        &group_key,
213                        &group,
214                        Vec::new(),
215                        Some(value),
216                        None,
217                        false,
218                        matched_exception_ids,
219                    ));
220                } else if matched_exception_classes.is_empty() && has_incomparable_strings(&group) {
221                    conflicts.push(conflict_from_group(
222                        &group_key,
223                        &group,
224                        rule_set,
225                        "unsupported_numeric_fold",
226                    ));
227                }
228            }
229            FoldClassV1::MaxOfMinima => {
230                if let Some(value) = max_numeric(&group) {
231                    entries.push(compiled_entry_from_group(
232                        &group_key,
233                        &group,
234                        Vec::new(),
235                        Some(value),
236                        None,
237                        false,
238                        matched_exception_ids,
239                    ));
240                } else if matched_exception_classes.is_empty() && has_incomparable_strings(&group) {
241                    conflicts.push(conflict_from_group(
242                        &group_key,
243                        &group,
244                        rule_set,
245                        "unsupported_numeric_fold",
246                    ));
247                }
248            }
249            FoldClassV1::EarliestExpiry => {
250                if let Some(expiry_at) = earliest_expiry_value(&group) {
251                    entries.push(compiled_entry_from_group(
252                        &group_key,
253                        &group,
254                        Vec::new(),
255                        None,
256                        Some(expiry_at),
257                        false,
258                        matched_exception_ids,
259                    ));
260                }
261            }
262            FoldClassV1::ConflictIfDifferent => {
263                let values = normalized_group_values(&group);
264                if values.len() > 1 && matched_exception_classes.is_empty() {
265                    conflicts.push(conflict_from_group(
266                        &group_key,
267                        &group,
268                        rule_set,
269                        "incomparable_values",
270                    ));
271                } else if !values.is_empty() || single_numeric_if_all_equal(&group).is_some() {
272                    entries.push(compiled_entry_from_group(
273                        &group_key,
274                        &group,
275                        values,
276                        single_numeric_if_all_equal(&group),
277                        earliest_expiry_value(&group),
278                        false,
279                        matched_exception_ids,
280                    ));
281                }
282            }
283            FoldClassV1::BlockDominant => {
284                if !blocking_contributions.is_empty() && matched_exception_classes.is_empty() {
285                    block_entries.push(BlockEntryV1 {
286                        block_key: format!("{}:{}", group_key.family, group_key.key),
287                        reason: group
288                            .iter()
289                            .map(|entry| entry.explanation.clone())
290                            .collect::<Vec<_>>()
291                            .join("; "),
292                        source_profile_refs: source_profile_refs(&group),
293                        admissible_exception_classes: admissible_exception_classes(
294                            &group, rule_set,
295                        ),
296                    });
297                } else {
298                    let string_values = union_values(&group);
299                    let numeric_value = single_numeric_if_all_equal(&group);
300                    let expiry_at = earliest_expiry_value(&group);
301                    if !string_values.is_empty() || numeric_value.is_some() || expiry_at.is_some() {
302                        entries.push(compiled_entry_from_group(
303                            &group_key,
304                            &group,
305                            string_values,
306                            numeric_value,
307                            expiry_at,
308                            false,
309                            matched_exception_ids,
310                        ));
311                    }
312                }
313            }
314        }
315    }
316
317    entries.sort_by(|a, b| {
318        (
319            &a.output_kind,
320            &a.obligation_family,
321            &a.obligation_key,
322            &a.string_values,
323        )
324            .cmp(&(
325                &b.output_kind,
326                &b.obligation_family,
327                &b.obligation_key,
328                &b.string_values,
329            ))
330    });
331    block_entries.sort_by(|a, b| a.block_key.cmp(&b.block_key));
332    conflicts.sort_by(|a, b| {
333        (&a.obligation_family, &a.obligation_key, &a.conflict_class).cmp(&(
334            &b.obligation_family,
335            &b.obligation_key,
336            &b.conflict_class,
337        ))
338    });
339
340    let hard_block_summary = block_entries
341        .iter()
342        .map(|entry| entry.block_key.clone())
343        .collect::<Vec<_>>();
344    let active_warning_summary = entries
345        .iter()
346        .filter(|entry| entry.output_kind == CompiledObligationKindV1::Warning)
347        .flat_map(|entry| entry.string_values.clone())
348        .collect::<Vec<_>>();
349    let approval_requirements =
350        collect_strings_by_kind(&entries, CompiledObligationKindV1::Approval);
351    let disclosure_obligations =
352        collect_strings_by_kind(&entries, CompiledObligationKindV1::Disclosure);
353    let residency_obligations =
354        collect_strings_by_kind(&entries, CompiledObligationKindV1::Residency);
355    let tenancy_obligations = collect_strings_by_kind(&entries, CompiledObligationKindV1::Tenancy);
356    let assurance_obligations =
357        collect_strings_by_kind(&entries, CompiledObligationKindV1::Assurance);
358    let monitoring_obligations =
359        collect_strings_by_kind(&entries, CompiledObligationKindV1::Monitor);
360    let continuity_obligations =
361        collect_strings_by_kind(&entries, CompiledObligationKindV1::Continuity);
362    let effect_obligations = collect_strings_by_kind(&entries, CompiledObligationKindV1::Effect);
363    let delegation_obligations =
364        collect_strings_by_kind(&entries, CompiledObligationKindV1::Delegation);
365    let replay_obligations = collect_strings_by_kind(&entries, CompiledObligationKindV1::Replay);
366    let required_checks = collect_strings_by_kind(&entries, CompiledObligationKindV1::Check);
367    let required_monitors = monitoring_obligations.clone();
368    let required_evidence_obligations =
369        collect_strings_by_kind(&entries, CompiledObligationKindV1::Evidence);
370    let required_disclosure_obligations = disclosure_obligations.clone();
371    let required_rollback_obligations =
372        collect_strings_by_kind(&entries, CompiledObligationKindV1::Rollback);
373    let required_compensation_obligations =
374        collect_strings_by_kind(&entries, CompiledObligationKindV1::Compensation);
375    let required_post_hoc_review_obligations =
376        collect_strings_by_kind(&entries, CompiledObligationKindV1::PostHocReview);
377    let blocking_conflicts = conflicts.iter().any(|conflict| conflict.blocking);
378    let current_mode_classification = if !hard_block_summary.is_empty() || blocking_conflicts {
379        ConstitutionModeV1::Blocked
380    } else if context.incident_mode.as_deref().unwrap_or("normal") != "normal" {
381        ConstitutionModeV1::Incident
382    } else {
383        ConstitutionModeV1::Normal
384    };
385
386    let effective_constitution_id = EffectiveConstitutionId::generate();
387    let compiled_obligation_set_id = CompiledObligationSetId::generate();
388    let conflict_set_id = if conflicts.is_empty() {
389        None
390    } else {
391        Some(CompositionConflictSetId::generate())
392    };
393
394    let effective_constitution = EffectiveConstitutionV1 {
395        schema_version: EFFECTIVE_CONSTITUTION_V1_SCHEMA.into(),
396        effective_constitution_id: effective_constitution_id.clone(),
397        applicability_context_ref: context.applicability_context_id.clone(),
398        profile_set_ref: profile_set.profile_set_id.clone(),
399        composition_rule_set_ref: rule_set.composition_rule_set_id.clone(),
400        base_law_refs: vec!["v25_effective_constitution".into()],
401        doctrine_refs: vec![REFERENCE_EVALUATOR_VERSION_V1.into()],
402        admitted_profile_refs: profile_set.all_profile_refs(),
403        admitted_exception_refs: active_exceptions
404            .iter()
405            .map(|exception| exception.profile_exception_bundle_id.clone())
406            .collect(),
407        current_mode_classification,
408        hard_block_summary,
409        active_warning_summary,
410        approval_requirements,
411        disclosure_obligations,
412        residency_obligations,
413        tenancy_obligations,
414        assurance_obligations,
415        monitoring_obligations,
416        continuity_obligations,
417        effect_obligations,
418        delegation_obligations,
419        replay_obligations,
420        valid_as_of: context.valid_as_of.clone(),
421        recorded_as_of: context.recorded_as_of.clone(),
422        explanation_summary: explanation_summary(context, &block_entries, &conflicts),
423    };
424
425    let compiled_obligation_set = CompiledObligationSetV1 {
426        schema_version: COMPILED_OBLIGATION_SET_V1_SCHEMA.into(),
427        compiled_obligation_set_id: compiled_obligation_set_id.clone(),
428        effective_constitution_ref: effective_constitution_id.clone(),
429        obligation_entries: entries,
430        block_entries,
431        required_approvals: effective_constitution.approval_requirements.clone(),
432        required_checks,
433        required_monitors,
434        required_evidence_obligations,
435        required_disclosure_obligations,
436        required_rollback_obligations,
437        required_compensation_obligations,
438        required_post_hoc_review_obligations,
439        promotion_eligibility_summary: eligibility_summary(
440            effective_constitution.current_mode_classification,
441            conflicts.iter().any(|entry| entry.blocking),
442            !effective_constitution.approval_requirements.is_empty(),
443        ),
444        release_eligibility_summary: eligibility_summary(
445            effective_constitution.current_mode_classification,
446            conflicts.iter().any(|entry| entry.blocking),
447            !effective_constitution.approval_requirements.is_empty(),
448        ),
449        effect_eligibility_summary: eligibility_summary(
450            effective_constitution.current_mode_classification,
451            conflicts.iter().any(|entry| entry.blocking),
452            !effective_constitution.approval_requirements.is_empty(),
453        ),
454        unresolved_dependency_refs: conflicts
455            .iter()
456            .map(|conflict| format!("{}:{}", conflict.obligation_family, conflict.obligation_key))
457            .collect(),
458    };
459
460    let input_digest = ContentDigest::compute_json(&serde_json::json!({
461        "context": context,
462        "profile_set": profile_set,
463        "rule_set": rule_set,
464        "contributions": contributions,
465        "active_exception_ids": active_exceptions
466            .iter()
467            .map(|exception| exception.profile_exception_bundle_id.to_string())
468            .collect::<Vec<_>>(),
469    }))
470    .map_err(|err| CompositionError::DigestFailed {
471        reason: err.to_string(),
472    })?;
473
474    let receipt_id = CompositionReceiptId::generate();
475    let receipt = CompositionReceiptV1 {
476        schema_version: COMPOSITION_RECEIPT_V1_SCHEMA.into(),
477        composition_receipt_id: receipt_id.clone(),
478        applicability_context_ref: context.applicability_context_id.clone(),
479        profile_set_ref: profile_set.profile_set_id.clone(),
480        composition_rule_set_ref: rule_set.composition_rule_set_id.clone(),
481        effective_constitution_ref: Some(effective_constitution_id),
482        compiled_obligation_set_ref: Some(compiled_obligation_set_id),
483        composition_conflict_set_ref: conflict_set_id.clone(),
484        degradation_markers: if conflicts.is_empty() {
485            Vec::new()
486        } else {
487            vec!["conflict_set_emitted".into()]
488        },
489        execution_context_ref: context.execution_context_ref.clone(),
490        replay_handle: format!(
491            "profile-runtime:{}:{}:{}",
492            context.applicability_context_id,
493            profile_set.profile_set_id,
494            rule_set.composition_rule_set_id
495        ),
496        normalized_input_digest: input_digest,
497        generated_at: generated_at.clone(),
498    };
499
500    let conflict_set = if conflicts.is_empty() {
501        None
502    } else {
503        Some(CompositionConflictSetV1 {
504            schema_version: COMPOSITION_CONFLICT_SET_V1_SCHEMA.into(),
505            composition_conflict_set_id: conflict_set_id.ok_or_else(|| {
506                CompositionError::DigestFailed {
507                    reason: "conflict_set_id is None despite non-empty conflicts".into(),
508                }
509            })?,
510            effective_constitution_ref: Some(
511                effective_constitution.effective_constitution_id.clone(),
512            ),
513            composition_receipt_ref: Some(receipt_id),
514            conflicts,
515            blocking: matches!(
516                effective_constitution.current_mode_classification,
517                ConstitutionModeV1::Blocked
518            ),
519        })
520    };
521
522    Ok(CompositionOutcomeV1 {
523        receipt,
524        effective_constitution,
525        compiled_obligation_set,
526        conflict_set,
527    })
528}
529
530/// Computes the observable policy delta between two effective constitutions and obligation sets.
531pub fn diff_policy_impact(
532    from_constitution: &EffectiveConstitutionV1,
533    from_obligations: &CompiledObligationSetV1,
534    to_constitution: &EffectiveConstitutionV1,
535    to_obligations: &CompiledObligationSetV1,
536    simulation_only: bool,
537) -> PolicyImpactDiffV1 {
538    let from_map = entry_map(from_obligations);
539    let to_map = entry_map(to_obligations);
540
541    let mut changed_families = BTreeSet::new();
542    let mut unchanged_families = BTreeSet::new();
543    let all_keys = from_map
544        .keys()
545        .chain(to_map.keys())
546        .cloned()
547        .collect::<BTreeSet<_>>();
548
549    for key in all_keys {
550        match (from_map.get(&key), to_map.get(&key)) {
551            (Some(left), Some(right)) if left == right => {
552                unchanged_families.insert(key.0.clone());
553            }
554            _ => {
555                changed_families.insert(key.0.clone());
556            }
557        }
558    }
559
560    let from_blocks = from_obligations
561        .block_entries
562        .iter()
563        .map(|entry| entry.block_key.clone())
564        .collect::<BTreeSet<_>>();
565    let to_blocks = to_obligations
566        .block_entries
567        .iter()
568        .map(|entry| entry.block_key.clone())
569        .collect::<BTreeSet<_>>();
570
571    let newly_blocked_paths = to_blocks
572        .difference(&from_blocks)
573        .cloned()
574        .collect::<Vec<_>>();
575    let newly_admitted_paths = from_blocks
576        .difference(&to_blocks)
577        .cloned()
578        .collect::<Vec<_>>();
579
580    let changed_approval_behavior = symmetric_difference_strings(
581        &from_obligations.required_approvals,
582        &to_obligations.required_approvals,
583    );
584    let changed_monitoring_behavior = symmetric_difference_strings(
585        &from_obligations.required_monitors,
586        &to_obligations.required_monitors,
587    );
588    let changed_disclosure_behavior = symmetric_difference_strings(
589        &from_obligations.required_disclosure_obligations,
590        &to_obligations.required_disclosure_obligations,
591    );
592    let changed_residency_behavior = symmetric_difference_strings(
593        &from_constitution.residency_obligations,
594        &to_constitution.residency_obligations,
595    );
596    let changed_incident_behavior = if from_constitution.current_mode_classification
597        != to_constitution.current_mode_classification
598    {
599        vec![format!(
600            "mode:{:?}->{:?}",
601            from_constitution.current_mode_classification,
602            to_constitution.current_mode_classification
603        )]
604    } else {
605        symmetric_difference_strings(
606            &from_constitution.continuity_obligations,
607            &to_constitution.continuity_obligations,
608        )
609    };
610
611    let mut migration_consequences = Vec::new();
612    if !newly_blocked_paths.is_empty() {
613        migration_consequences.push("additional paths now require review or exception".into());
614    }
615    if !newly_admitted_paths.is_empty() {
616        migration_consequences
617            .push("previously blocked paths are now conditionally admissible".into());
618    }
619    if !changed_monitoring_behavior.is_empty() {
620        migration_consequences.push("monitoring plan update required".into());
621    }
622
623    PolicyImpactDiffV1 {
624        schema_version: POLICY_IMPACT_DIFF_V1_SCHEMA.into(),
625        policy_impact_diff_id: PolicyImpactDiffId::generate(),
626        from_effective_constitution_ref: from_constitution.effective_constitution_id.clone(),
627        to_effective_constitution_ref: to_constitution.effective_constitution_id.clone(),
628        changed_obligation_families: changed_families.into_iter().collect(),
629        unchanged_obligation_families: unchanged_families.into_iter().collect(),
630        newly_blocked_paths,
631        newly_admitted_paths,
632        changed_approval_behavior,
633        changed_monitoring_behavior,
634        changed_disclosure_behavior,
635        changed_residency_behavior,
636        changed_incident_behavior,
637        migration_consequences,
638        simulation_only,
639        explanation_summary: "policy impact diff derived from compiled obligation deltas".into(),
640    }
641}
642
643fn active_exceptions<'a>(
644    context: &ApplicabilityContextV1,
645    exceptions: &'a [ProfileExceptionBundleV1],
646) -> Vec<&'a ProfileExceptionBundleV1> {
647    let mut active = exceptions
648        .iter()
649        .filter(|exception| exception.is_active_as_of(&context.valid_as_of))
650        .filter(|exception| {
651            exception.affected_context_refs.is_empty()
652                || exception
653                    .affected_context_refs
654                    .iter()
655                    .any(|id| id == &context.applicability_context_id)
656        })
657        .collect::<Vec<_>>();
658    active.sort_by(|a, b| {
659        a.profile_exception_bundle_id
660            .cmp(&b.profile_exception_bundle_id)
661    });
662    active
663}
664
665fn matched_exception_ids(
666    group: &[&ObligationContributionV1],
667    active_exceptions: &[&ProfileExceptionBundleV1],
668    rule_set: &CompositionRuleSetV1,
669) -> Vec<stack_ids::ProfileExceptionBundleId> {
670    let mut ids = active_exceptions
671        .iter()
672        .filter(|exception| exception_matches_group(group, exception, rule_set))
673        .map(|exception| exception.profile_exception_bundle_id.clone())
674        .collect::<Vec<_>>();
675    ids.sort();
676    ids.dedup();
677    ids
678}
679
680fn matched_exception_classes(
681    group: &[&ObligationContributionV1],
682    active_exceptions: &[&ProfileExceptionBundleV1],
683    rule_set: &CompositionRuleSetV1,
684) -> Vec<String> {
685    let mut classes = active_exceptions
686        .iter()
687        .filter(|exception| exception_matches_group(group, exception, rule_set))
688        .map(|exception| exception.exception_class.clone())
689        .collect::<Vec<_>>();
690    classes.sort();
691    classes.dedup();
692    classes
693}
694
695fn exception_matches_group(
696    group: &[&ObligationContributionV1],
697    exception: &ProfileExceptionBundleV1,
698    rule_set: &CompositionRuleSetV1,
699) -> bool {
700    group.iter().any(|entry| {
701        let allowed_by_group = entry
702            .admissible_exception_classes
703            .iter()
704            .any(|class| class == &exception.exception_class);
705        let allowed_by_rule = rule_set
706            .family_rule(&entry.obligation_family)
707            .map(|rule| {
708                rule.admissible_exception_classes
709                    .iter()
710                    .any(|class| class == &exception.exception_class)
711            })
712            .unwrap_or(false);
713        allowed_by_group || allowed_by_rule
714    })
715}
716
717fn compiled_entry_from_group(
718    group_key: &GroupKey,
719    group: &[&ObligationContributionV1],
720    string_values: Vec<String>,
721    numeric_value: Option<i64>,
722    expiry_at: Option<String>,
723    blocking: bool,
724    source_exception_refs: Vec<stack_ids::ProfileExceptionBundleId>,
725) -> CompiledObligationEntryV1 {
726    CompiledObligationEntryV1 {
727        obligation_entry_id: format!("{}:{}", group_key.family, group_key.key),
728        obligation_family: group_key.family.clone(),
729        obligation_key: group_key.key.clone(),
730        output_kind: group_key.output_kind,
731        fold_class: group_key.fold_class,
732        string_values,
733        numeric_value,
734        expiry_at,
735        blocking,
736        source_profile_refs: source_profile_refs(group),
737        source_exception_refs,
738        explanation: group
739            .iter()
740            .map(|entry| entry.explanation.clone())
741            .collect::<Vec<_>>()
742            .join("; "),
743    }
744}
745
746fn conflict_from_group(
747    group_key: &GroupKey,
748    group: &[&ObligationContributionV1],
749    rule_set: &CompositionRuleSetV1,
750    conflict_class: &str,
751) -> CompositionConflictV1 {
752    let conflict_rule = rule_set.conflict_class(conflict_class);
753    CompositionConflictV1 {
754        obligation_family: group_key.family.clone(),
755        obligation_key: group_key.key.clone(),
756        conflict_class: conflict_class.into(),
757        blocking: conflict_rule.map(|rule| rule.blocking).unwrap_or(true),
758        source_profile_refs: source_profile_refs(group),
759        implicated_values: normalized_group_values(group),
760        admissible_exception_classes: admissible_exception_classes(group, rule_set),
761        review_path: conflict_rule
762            .map(|rule| rule.review_path.clone())
763            .unwrap_or_else(|| "human_constitution_review".into()),
764        explanation: conflict_rule
765            .map(|rule| rule.explanation.clone())
766            .unwrap_or_else(|| {
767                group
768                    .iter()
769                    .map(|entry| entry.explanation.clone())
770                    .collect::<Vec<_>>()
771                    .join("; ")
772            }),
773    }
774}
775
776fn admissible_exception_classes(
777    group: &[&ObligationContributionV1],
778    rule_set: &CompositionRuleSetV1,
779) -> Vec<String> {
780    let mut classes = group
781        .iter()
782        .flat_map(|entry| entry.admissible_exception_classes.clone())
783        .collect::<Vec<_>>();
784    for family in group.iter().map(|entry| entry.obligation_family.as_str()) {
785        if let Some(rule) = rule_set.family_rule(family) {
786            classes.extend(rule.admissible_exception_classes.iter().cloned());
787        }
788    }
789    classes.sort();
790    classes.dedup();
791    classes
792}
793
794fn source_profile_refs(group: &[&ObligationContributionV1]) -> Vec<String> {
795    let mut refs = group
796        .iter()
797        .map(|entry| entry.source_profile_ref.clone())
798        .collect::<Vec<_>>();
799    refs.sort();
800    refs.dedup();
801    refs
802}
803
804fn normalized_group_values(group: &[&ObligationContributionV1]) -> Vec<String> {
805    let mut values = union_values(group);
806    if values.is_empty() {
807        for entry in group {
808            if let Some(value) = entry.numeric_value {
809                values.push(value.to_string());
810            }
811            if let Some(value) = &entry.expiry_at {
812                values.push(value.clone());
813            }
814        }
815    }
816    values.sort();
817    values.dedup();
818    values
819}
820
821fn union_values(group: &[&ObligationContributionV1]) -> Vec<String> {
822    let mut values = group
823        .iter()
824        .flat_map(|entry| entry.string_values.iter().cloned())
825        .collect::<Vec<_>>();
826    values.sort();
827    values.dedup();
828    values
829}
830
831fn intersect_values(group: &[&ObligationContributionV1]) -> Vec<String> {
832    let sets = group
833        .iter()
834        .filter(|entry| !entry.string_values.is_empty())
835        .map(|entry| entry.string_values.iter().cloned().collect::<BTreeSet<_>>())
836        .collect::<Vec<_>>();
837    if sets.is_empty() {
838        return Vec::new();
839    }
840    let mut iter = sets.into_iter();
841    let Some(first) = iter.next() else {
842        return Vec::new();
843    };
844    let intersection = iter.fold(first, |acc, item| {
845        acc.intersection(&item).cloned().collect()
846    });
847    intersection.into_iter().collect()
848}
849
850fn numeric_value(entry: &ObligationContributionV1) -> Option<i64> {
851    entry.numeric_value.or_else(|| {
852        entry
853            .string_values
854            .first()
855            .and_then(|value| value.parse::<i64>().ok())
856    })
857}
858
859fn min_numeric(group: &[&ObligationContributionV1]) -> Option<i64> {
860    group.iter().filter_map(|entry| numeric_value(entry)).min()
861}
862
863fn max_numeric(group: &[&ObligationContributionV1]) -> Option<i64> {
864    group.iter().filter_map(|entry| numeric_value(entry)).max()
865}
866
867fn single_numeric_if_all_equal(group: &[&ObligationContributionV1]) -> Option<i64> {
868    let mut values = group
869        .iter()
870        .filter_map(|entry| numeric_value(entry))
871        .collect::<Vec<_>>();
872    values.sort();
873    values.dedup();
874    match values.as_slice() {
875        [value] => Some(*value),
876        _ => None,
877    }
878}
879
880fn earliest_expiry_value(group: &[&ObligationContributionV1]) -> Option<String> {
881    group
882        .iter()
883        .filter_map(|entry| entry.expiry_at.clone())
884        .min()
885}
886
887fn has_incomparable_strings(group: &[&ObligationContributionV1]) -> bool {
888    let values = union_values(group);
889    values.len() > 1
890}
891
892fn collect_strings_by_kind(
893    entries: &[CompiledObligationEntryV1],
894    kind: CompiledObligationKindV1,
895) -> Vec<String> {
896    let mut values = entries
897        .iter()
898        .filter(|entry| entry.output_kind == kind)
899        .flat_map(|entry| {
900            let mut strings = entry.string_values.clone();
901            if let Some(value) = entry.numeric_value {
902                strings.push(value.to_string());
903            }
904            if let Some(value) = &entry.expiry_at {
905                strings.push(value.clone());
906            }
907            strings
908        })
909        .collect::<Vec<_>>();
910    values.sort();
911    values.dedup();
912    values
913}
914
915fn explanation_summary(
916    context: &ApplicabilityContextV1,
917    block_entries: &[BlockEntryV1],
918    conflicts: &[CompositionConflictV1],
919) -> String {
920    if !block_entries.is_empty() {
921        format!(
922            "context '{}' is blocked by {} hard blocks and {} conflicts",
923            context.target_surface_kind,
924            block_entries.len(),
925            conflicts.len()
926        )
927    } else if !conflicts.is_empty() {
928        format!(
929            "context '{}' emitted {} composition conflicts",
930            context.target_surface_kind,
931            conflicts.len()
932        )
933    } else {
934        format!(
935            "context '{}' compiled without blocking conflicts",
936            context.target_surface_kind
937        )
938    }
939}
940
941fn eligibility_summary(
942    current_mode: ConstitutionModeV1,
943    blocking_conflicts: bool,
944    approval_required: bool,
945) -> String {
946    if matches!(current_mode, ConstitutionModeV1::Blocked) || blocking_conflicts {
947        "blocked".into()
948    } else if approval_required {
949        "approval_required".into()
950    } else {
951        "eligible".into()
952    }
953}
954
955fn entry_map(
956    obligations: &CompiledObligationSetV1,
957) -> BTreeMap<ObligationEntryMapKey, ObligationEntryMapValue> {
958    obligations
959        .obligation_entries
960        .iter()
961        .map(|entry| {
962            (
963                (
964                    entry.obligation_family.clone(),
965                    entry.obligation_key.clone(),
966                ),
967                (
968                    entry.string_values.clone(),
969                    entry.numeric_value,
970                    entry.expiry_at.clone(),
971                    entry.blocking,
972                ),
973            )
974        })
975        .collect()
976}
977
978fn symmetric_difference_strings(left: &[String], right: &[String]) -> Vec<String> {
979    let left = left.iter().cloned().collect::<BTreeSet<_>>();
980    let right = right.iter().cloned().collect::<BTreeSet<_>>();
981    left.symmetric_difference(&right).cloned().collect()
982}