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 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 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
113pub 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
530pub 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}