1use std::collections::{BTreeMap, BTreeSet};
41
42use serde::{Deserialize, Serialize};
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum DiscordKind {
48 Conflict,
50 ConflictingConfidence,
52 MissingOverlap,
54 TranslationFail,
56 EvidenceGap,
58 ReplicationFail,
60 ProvenanceFragile,
62 StatusDivergent,
64 MethodMismatch,
67}
68
69impl DiscordKind {
70 pub const ALL: &'static [DiscordKind] = &[
72 DiscordKind::Conflict,
73 DiscordKind::ConflictingConfidence,
74 DiscordKind::MissingOverlap,
75 DiscordKind::TranslationFail,
76 DiscordKind::EvidenceGap,
77 DiscordKind::ReplicationFail,
78 DiscordKind::ProvenanceFragile,
79 DiscordKind::StatusDivergent,
80 DiscordKind::MethodMismatch,
81 ];
82
83 #[must_use]
85 pub fn as_str(&self) -> &'static str {
86 match self {
87 Self::Conflict => "conflict",
88 Self::ConflictingConfidence => "conflicting_confidence",
89 Self::MissingOverlap => "missing_overlap",
90 Self::TranslationFail => "translation_fail",
91 Self::EvidenceGap => "evidence_gap",
92 Self::ReplicationFail => "replication_fail",
93 Self::ProvenanceFragile => "provenance_fragile",
94 Self::StatusDivergent => "status_divergent",
95 Self::MethodMismatch => "method_mismatch",
96 }
97 }
98}
99
100impl std::fmt::Display for DiscordKind {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 write!(f, "{}", self.as_str())
103 }
104}
105
106#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
110pub struct DiscordSet {
111 kinds: BTreeSet<DiscordKind>,
112}
113
114impl DiscordSet {
115 #[must_use]
117 pub fn empty() -> Self {
118 Self::default()
119 }
120
121 #[must_use]
123 pub fn singleton(kind: DiscordKind) -> Self {
124 let mut s = Self::default();
125 s.kinds.insert(kind);
126 s
127 }
128
129 pub fn from_kinds(kinds: impl IntoIterator<Item = DiscordKind>) -> Self {
131 Self {
132 kinds: kinds.into_iter().collect(),
133 }
134 }
135
136 #[must_use]
138 pub fn is_empty(&self) -> bool {
139 self.kinds.is_empty()
140 }
141
142 #[must_use]
144 pub fn len(&self) -> usize {
145 self.kinds.len()
146 }
147
148 #[must_use]
150 pub fn contains(&self, kind: DiscordKind) -> bool {
151 self.kinds.contains(&kind)
152 }
153
154 pub fn insert(&mut self, kind: DiscordKind) -> bool {
156 self.kinds.insert(kind)
157 }
158
159 pub fn join(&self, other: &Self) -> Self {
161 Self {
162 kinds: self.kinds.union(&other.kinds).copied().collect(),
163 }
164 }
165
166 pub fn meet(&self, other: &Self) -> Self {
168 Self {
169 kinds: self.kinds.intersection(&other.kinds).copied().collect(),
170 }
171 }
172
173 #[must_use]
175 pub fn is_subset(&self, other: &Self) -> bool {
176 self.kinds.is_subset(&other.kinds)
177 }
178
179 pub fn iter(&self) -> impl Iterator<Item = DiscordKind> + '_ {
181 self.kinds.iter().copied()
182 }
183}
184
185impl std::fmt::Display for DiscordSet {
186 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187 if self.kinds.is_empty() {
188 return write!(f, "{{}}");
189 }
190 write!(f, "{{")?;
191 let mut first = true;
192 for kind in &self.kinds {
193 if !first {
194 write!(f, ", ")?;
195 }
196 first = false;
197 write!(f, "{kind}")?;
198 }
199 write!(f, "}}")
200 }
201}
202
203pub type ContextId = String;
206
207pub trait ContextRefinement {
213 fn refines(&self, c_prime: &str, c: &str) -> bool;
215}
216
217pub trait Detector {
232 fn kind(&self) -> DiscordKind;
234
235 fn fires(&self, context: &str) -> bool;
237}
238
239#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
246pub struct DiscordAssignment {
247 by_context: BTreeMap<ContextId, DiscordSet>,
248}
249
250impl DiscordAssignment {
251 #[must_use]
253 pub fn empty() -> Self {
254 Self::default()
255 }
256
257 pub fn build_from_detectors<D: Detector>(detectors: &[D], contexts: &[ContextId]) -> Self {
264 let mut by_context = BTreeMap::new();
265 for context in contexts {
266 let mut set = DiscordSet::default();
267 for detector in detectors {
268 if detector.fires(context) {
269 set.insert(detector.kind());
270 }
271 }
272 by_context.insert(context.clone(), set);
273 }
274 Self { by_context }
275 }
276
277 pub fn set(&mut self, context: impl Into<ContextId>, kinds: DiscordSet) {
279 self.by_context.insert(context.into(), kinds);
280 }
281
282 pub fn get(&self, context: &str) -> DiscordSet {
284 self.by_context.get(context).cloned().unwrap_or_default()
285 }
286
287 pub fn iter(&self) -> impl Iterator<Item = (&ContextId, &DiscordSet)> {
289 self.by_context.iter()
290 }
291
292 pub fn frontier_support(&self) -> FrontierSupport {
295 let contexts = self
296 .by_context
297 .iter()
298 .filter(|(_, set)| !set.is_empty())
299 .map(|(c, _)| c.clone())
300 .collect();
301 FrontierSupport { contexts }
302 }
303
304 pub fn context_count(&self) -> usize {
306 self.by_context.len()
307 }
308}
309
310#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
317pub struct FrontierSupport {
318 contexts: BTreeSet<ContextId>,
319}
320
321impl FrontierSupport {
322 pub fn contains(&self, context: &str) -> bool {
324 self.contexts.contains(context)
325 }
326
327 pub fn len(&self) -> usize {
329 self.contexts.len()
330 }
331
332 #[must_use]
334 pub fn is_empty(&self) -> bool {
335 self.contexts.is_empty()
336 }
337
338 pub fn iter(&self) -> impl Iterator<Item = &ContextId> {
340 self.contexts.iter()
341 }
342
343 pub fn is_upward_closed<R: ContextRefinement>(
355 &self,
356 refinement: &R,
357 universe: &[ContextId],
358 ) -> bool {
359 for c_prime in &self.contexts {
360 for c in universe {
361 if c_prime != c && refinement.refines(c_prime, c) && !self.contexts.contains(c) {
362 return false;
363 }
364 }
365 }
366 true
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 struct PosetRefinement {
377 refines_map: BTreeMap<ContextId, BTreeSet<ContextId>>,
379 }
380
381 impl PosetRefinement {
382 fn new(direct_edges: &[(&str, &str)], universe: &[&str]) -> Self {
383 let mut refines_map: BTreeMap<ContextId, BTreeSet<ContextId>> = BTreeMap::new();
386 for c in universe {
388 refines_map
389 .entry(c.to_string())
390 .or_default()
391 .insert(c.to_string());
392 }
393 for (cp, c) in direct_edges {
395 refines_map
396 .entry(cp.to_string())
397 .or_default()
398 .insert(c.to_string());
399 }
400 let all: Vec<ContextId> = universe.iter().map(|s| s.to_string()).collect();
402 for k in &all {
403 for i in &all {
404 for j in &all {
405 if refines_map.get(i).is_some_and(|s| s.contains(k))
406 && refines_map.get(k).is_some_and(|s| s.contains(j))
407 {
408 refines_map.entry(i.clone()).or_default().insert(j.clone());
409 }
410 }
411 }
412 }
413 Self { refines_map }
414 }
415 }
416
417 impl ContextRefinement for PosetRefinement {
418 fn refines(&self, c_prime: &str, c: &str) -> bool {
419 self.refines_map
420 .get(c_prime)
421 .is_some_and(|set| set.contains(c))
422 }
423 }
424
425 struct FixedDetector {
427 kind: DiscordKind,
428 fires_on: BTreeSet<String>,
429 }
430
431 impl FixedDetector {
432 fn new(kind: DiscordKind, fires_on: &[&str]) -> Self {
433 Self {
434 kind,
435 fires_on: fires_on.iter().map(|s| (*s).to_string()).collect(),
436 }
437 }
438 }
439
440 impl Detector for FixedDetector {
441 fn kind(&self) -> DiscordKind {
442 self.kind
443 }
444 fn fires(&self, context: &str) -> bool {
445 self.fires_on.contains(context)
446 }
447 }
448
449 fn ctxs(names: &[&str]) -> Vec<ContextId> {
450 names.iter().map(|s| (*s).to_string()).collect()
451 }
452
453 #[test]
454 fn discord_kinds_round_trip_serde() {
455 for kind in DiscordKind::ALL {
456 let json = serde_json::to_string(kind).unwrap();
457 let back: DiscordKind = serde_json::from_str(&json).unwrap();
458 assert_eq!(*kind, back);
459 }
460 }
461
462 #[test]
463 fn discord_set_join_is_union() {
464 let a = DiscordSet::from_kinds([DiscordKind::Conflict, DiscordKind::EvidenceGap]);
465 let b = DiscordSet::from_kinds([DiscordKind::EvidenceGap, DiscordKind::MethodMismatch]);
466 let joined = a.join(&b);
467 assert_eq!(joined.len(), 3);
468 assert!(joined.contains(DiscordKind::Conflict));
469 assert!(joined.contains(DiscordKind::EvidenceGap));
470 assert!(joined.contains(DiscordKind::MethodMismatch));
471 }
472
473 #[test]
474 fn discord_set_meet_is_intersection() {
475 let a = DiscordSet::from_kinds([DiscordKind::Conflict, DiscordKind::EvidenceGap]);
476 let b = DiscordSet::from_kinds([DiscordKind::EvidenceGap, DiscordKind::MethodMismatch]);
477 let met = a.meet(&b);
478 assert_eq!(met.len(), 1);
479 assert!(met.contains(DiscordKind::EvidenceGap));
480 }
481
482 #[test]
483 fn empty_assignment_has_empty_support() {
484 let assignment = DiscordAssignment::empty();
485 assert!(assignment.frontier_support().is_empty());
486 }
487
488 #[test]
489 fn theorem_4_monotone_detector_yields_upward_closed_support() {
490 let universe = ctxs(&["c1", "c2", "c3"]);
493 let refinement = PosetRefinement::new(&[("c1", "c2"), ("c2", "c3")], &["c1", "c2", "c3"]);
494
495 let monotone_conflict = FixedDetector::new(DiscordKind::Conflict, &["c1", "c2", "c3"]);
498 let assignment = DiscordAssignment::build_from_detectors(&[monotone_conflict], &universe);
499
500 let support = assignment.frontier_support();
501 assert!(support.is_upward_closed(&refinement, &universe));
502 assert!(support.contains("c1"));
503 assert!(support.contains("c2"));
504 assert!(support.contains("c3"));
505 }
506
507 #[test]
508 fn theorem_4_violation_when_detector_is_not_monotone() {
509 let universe = ctxs(&["c1", "c2", "c3"]);
514 let refinement = PosetRefinement::new(&[("c1", "c2"), ("c2", "c3")], &["c1", "c2", "c3"]);
515
516 let buggy = FixedDetector::new(DiscordKind::Conflict, &["c1"]);
517 let assignment = DiscordAssignment::build_from_detectors(&[buggy], &universe);
518 let support = assignment.frontier_support();
519
520 assert!(!support.is_upward_closed(&refinement, &universe));
525 assert!(support.contains("c1"));
526 assert!(!support.contains("c2"));
527 assert!(!support.contains("c3"));
528 }
529
530 #[test]
531 fn theorem_4_holds_for_pointwise_union_of_monotone_detectors() {
532 let universe = ctxs(&["c1", "c2", "c3", "c4"]);
535 let refinement = PosetRefinement::new(
536 &[("c1", "c2"), ("c2", "c4"), ("c3", "c4")],
537 &["c1", "c2", "c3", "c4"],
538 );
539 let det1 = FixedDetector::new(DiscordKind::Conflict, &["c1", "c2", "c4"]);
541 let det2 = FixedDetector::new(DiscordKind::EvidenceGap, &["c3", "c4"]);
543
544 let assignment = DiscordAssignment::build_from_detectors(&[det1, det2], &universe);
545 let support = assignment.frontier_support();
546 assert!(support.is_upward_closed(&refinement, &universe));
547 assert_eq!(support.len(), 4);
548 }
549
550 #[test]
551 fn frontier_support_contains_only_nonempty_contexts() {
552 let universe = ctxs(&["c1", "c2", "c3"]);
553 let det = FixedDetector::new(DiscordKind::Conflict, &["c1", "c3"]);
554 let assignment = DiscordAssignment::build_from_detectors(&[det], &universe);
555 let support = assignment.frontier_support();
556 assert!(support.contains("c1"));
557 assert!(!support.contains("c2"));
558 assert!(support.contains("c3"));
559 assert_eq!(support.len(), 2);
560 }
561
562 #[test]
563 fn manual_assignment_set_and_get() {
564 let mut a = DiscordAssignment::empty();
565 a.set("c1", DiscordSet::singleton(DiscordKind::ReplicationFail));
566 assert_eq!(a.get("c1").len(), 1);
567 assert!(a.get("c1").contains(DiscordKind::ReplicationFail));
568 assert!(a.get("c2").is_empty());
569 }
570
571 #[test]
572 fn poset_refinement_is_reflexive() {
573 let r = PosetRefinement::new(&[("c1", "c2")], &["c1", "c2"]);
574 assert!(r.refines("c1", "c1"));
575 assert!(r.refines("c2", "c2"));
576 }
577
578 #[test]
579 fn poset_refinement_is_transitive() {
580 let r = PosetRefinement::new(&[("a", "b"), ("b", "c")], &["a", "b", "c"]);
581 assert!(r.refines("a", "b"));
582 assert!(r.refines("b", "c"));
583 assert!(r.refines("a", "c"));
584 }
585
586 #[test]
587 fn discord_kind_display() {
588 assert_eq!(DiscordKind::Conflict.to_string(), "conflict");
589 assert_eq!(
590 DiscordKind::ConflictingConfidence.to_string(),
591 "conflicting_confidence"
592 );
593 assert_eq!(DiscordKind::MethodMismatch.to_string(), "method_mismatch");
594 }
595
596 #[test]
597 fn assignment_serde_round_trip() {
598 let mut a = DiscordAssignment::empty();
599 a.set(
600 "c1",
601 DiscordSet::from_kinds([DiscordKind::Conflict, DiscordKind::EvidenceGap]),
602 );
603 let json = serde_json::to_string(&a).unwrap();
604 let back: DiscordAssignment = serde_json::from_str(&json).unwrap();
605 assert_eq!(back, a);
606 }
607}