eunoia 0.7.0

A library for creating area-proportional Euler and Venn diagrams
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
//! Diagram specification and construction.
//!
//! This module provides types for defining Euler and Venn diagram specifications through
//! set combinations and their values.

mod combination;
mod input;
mod spec_builder;

pub use crate::error::DiagramError;
pub use combination::Combination;
pub use input::InputType;
pub use spec_builder::DiagramSpecBuilder;

use std::collections::HashMap;

/// Represents a complete Euler or Venn diagram specification.
///
/// This contains the input data (set sizes and intersections) that describes
/// what the diagram should represent. The actual geometric shapes will be
/// computed during the fitting process.
///
/// Both exclusive and inclusive representations are stored for efficient access.
///
/// The diagram specification is shape-agnostic - the shape type is determined
/// when fitting the diagram, not when building the specification.
#[derive(Debug, Clone)]
pub struct DiagramSpec {
    /// Exclusive areas (unique parts of each combination)
    pub(crate) exclusive_areas: HashMap<Combination, f64>,

    /// Inclusive areas (inclusive of all subsets)
    pub(crate) inclusive_areas: HashMap<Combination, f64>,

    /// How the input values were originally specified.
    pub(crate) input_type: InputType,

    /// Set of all unique set names in the diagram (ordered).
    pub(crate) set_names: Vec<String>,
}

impl DiagramSpec {
    /// Returns the input type for this diagram specification.
    pub fn input_type(&self) -> InputType {
        self.input_type
    }

    /// Returns the set names in this diagram specification (in order).
    pub fn set_names(&self) -> &[String] {
        &self.set_names
    }

    /// Returns the exclusive areas.
    pub fn exclusive_areas(&self) -> &HashMap<Combination, f64> {
        &self.exclusive_areas
    }

    /// Returns the inclusive areas.
    pub fn inclusive_areas(&self) -> &HashMap<Combination, f64> {
        &self.inclusive_areas
    }

    /// Gets the exclusive area for a specific combination.
    pub fn get_exclusive(&self, combination: &Combination) -> Option<f64> {
        self.exclusive_areas.get(combination).copied()
    }

    /// Gets the inclusive area for a specific combination.
    pub fn get_inclusive(&self, combination: &Combination) -> Option<f64> {
        self.inclusive_areas.get(combination).copied()
    }

    /// Preprocess the specification for fitting (internal use).
    ///
    /// This:
    /// 1. Removes empty sets (area < ε)
    /// 2. Removes combinations containing empty sets
    /// 3. Computes pairwise relationships (subset, disjoint)
    pub(crate) fn preprocess(&self) -> Result<PreprocessedSpec, DiagramError> {
        const EPSILON: f64 = 1e-10; // sqrt of machine epsilon

        // 1. Find empty sets (use inclusive areas to determine empty sets)
        let mut non_empty_sets = Vec::new();
        let mut set_to_idx = HashMap::new();

        for set_name in self.set_names.iter() {
            let combo = Combination::new(&[set_name]);
            if let Some(&area) = self.inclusive_areas.get(&combo) {
                if area >= EPSILON {
                    let idx = non_empty_sets.len();
                    non_empty_sets.push(set_name.clone());
                    set_to_idx.insert(set_name.clone(), idx);
                }
            }
        }

        let n_sets = non_empty_sets.len();

        if n_sets <= 1 {
            return Err(DiagramError::InvalidCombination(
                "Need at least 2 non-empty sets".to_string(),
            ));
        }

        // 2. Filter combinations to only include non-empty sets
        let mut filtered_exclusive = HashMap::new();
        let mut filtered_inclusive = HashMap::new();

        // First, add all combinations from exclusive
        for (combo, &area) in self.exclusive_areas.iter() {
            // Check if all sets in this combination are non-empty
            let all_non_empty = combo.sets().iter().all(|s| set_to_idx.contains_key(s));

            if all_non_empty {
                filtered_exclusive.insert(combo.clone(), area);
                if let Some(&inclusive_area) = self.inclusive_areas.get(combo) {
                    filtered_inclusive.insert(combo.clone(), inclusive_area);
                }
            }
        }

        // Also add combinations from inclusive that might have zero exclusive area
        for (combo, &inclusive_area) in self.inclusive_areas.iter() {
            let all_non_empty = combo.sets().iter().all(|s| set_to_idx.contains_key(s));

            if all_non_empty && inclusive_area > 1e-10 && !filtered_inclusive.contains_key(combo) {
                filtered_inclusive.insert(combo.clone(), inclusive_area);
                // Add to exclusive with 0 if not already there
                if !filtered_exclusive.contains_key(combo) {
                    filtered_exclusive.insert(combo.clone(), 0.0);
                }
            }
        }

        // 3. Compute set areas
        let mut set_areas = vec![0.0; n_sets];
        for (i, set_name) in non_empty_sets.iter().enumerate() {
            let combo = Combination::new(&[set_name]);
            if let Some(&area) = filtered_inclusive.get(&combo) {
                set_areas[i] = area;
            }
        }

        // 4. Compute pairwise relationships
        let relationships = Self::compute_pairwise_relations(&non_empty_sets, &filtered_inclusive)?;

        // 5. Convert Combination maps to RegionMask maps for efficient internal use
        use crate::geometry::diagram;
        let exclusive_areas_mask = filtered_exclusive
            .iter()
            .map(|(combo, &area)| {
                let mask = diagram::combination_to_mask(combo, &non_empty_sets);
                (mask, area)
            })
            .collect();

        let inclusive_areas_mask = filtered_inclusive
            .iter()
            .map(|(combo, &area)| {
                let mask = diagram::combination_to_mask(combo, &non_empty_sets);
                (mask, area)
            })
            .collect();

        Ok(PreprocessedSpec {
            set_names: non_empty_sets,
            set_to_idx,
            exclusive_areas: exclusive_areas_mask,
            inclusive_areas: inclusive_areas_mask,
            n_sets,
            set_areas,
            relationships,
        })
    }

    fn compute_pairwise_relations(
        set_names: &[String],
        inclusive_areas: &HashMap<Combination, f64>,
    ) -> Result<PairwiseRelations, DiagramError> {
        let n = set_names.len();

        // Initialize relationship matrices
        let mut subset = vec![vec![false; n]; n];
        let mut disjoint = vec![vec![false; n]; n];
        let mut overlap_areas = vec![vec![0.0; n]; n];

        // Check all pairs
        for i in 0..n {
            for j in (i + 1)..n {
                let set_i = &set_names[i];
                let set_j = &set_names[j];

                let combo_i = Combination::new(&[set_i]);
                let combo_j = Combination::new(&[set_j]);
                let combo_ij = Combination::new(&[set_i, set_j]);

                let area_i = inclusive_areas.get(&combo_i).copied().unwrap_or(0.0);
                let area_j = inclusive_areas.get(&combo_j).copied().unwrap_or(0.0);
                let area_ij_inclusive = inclusive_areas.get(&combo_ij).copied().unwrap_or(0.0);

                // Store overlap area - use inclusive intersection
                // This represents the total geometric intersection including higher-order overlaps
                overlap_areas[i][j] = area_ij_inclusive;
                overlap_areas[j][i] = area_ij_inclusive;

                // Check if disjoint (intersection is zero)
                if area_ij_inclusive < 1e-10 {
                    disjoint[i][j] = true;
                    disjoint[j][i] = true;
                }

                // Check if one is subset of another
                // j ⊆ i if inclusive area(i ∩ j) == area(j)
                if (area_ij_inclusive - area_j).abs() < 1e-10 {
                    subset[i][j] = true; // j is subset of i
                }
                if (area_ij_inclusive - area_i).abs() < 1e-10 {
                    subset[j][i] = true; // i is subset of j
                }
            }
        }

        Ok(PairwiseRelations {
            n_sets: n,
            subset,
            disjoint,
            overlap_areas,
        })
    }

    /// Convert exclusive areas to inclusive areas (static version for builder).
    fn exclusive_to_inclusive_static(
        exclusive: &HashMap<Combination, f64>,
    ) -> Result<HashMap<Combination, f64>, DiagramError> {
        let mut inclusive: HashMap<Combination, f64> = HashMap::new();

        // First, collect all unique set names
        let mut all_sets = std::collections::HashSet::new();
        for combo in exclusive.keys() {
            for set_name in combo.sets() {
                all_sets.insert(set_name.clone());
            }
        }
        let all_sets: Vec<String> = all_sets.into_iter().collect();
        let n_sets = all_sets.len();

        // Generate all possible combinations (power set excluding empty)
        for mask in 1..(1 << n_sets) {
            let mut combo_sets = Vec::new();
            for (i, set_name) in all_sets.iter().enumerate() {
                if (mask & (1 << i)) != 0 {
                    combo_sets.push(set_name.as_str());
                }
            }
            let combo = Combination::new(&combo_sets);

            // Compute inclusive area = sum of exclusive areas of this combo and all its supersets
            let mut inclusive_area = 0.0;
            for (other_combo, &other_excl) in exclusive.iter() {
                // Include if other_combo contains all sets in combo
                if other_combo.contains_all(&combo) {
                    inclusive_area += other_excl;
                }
            }

            // Only include if non-zero
            if inclusive_area > 1e-10 {
                inclusive.insert(combo, inclusive_area);
            }
        }

        Ok(inclusive)
    }

    /// Convert inclusive areas to exclusive areas (static version for builder).
    fn inclusive_to_exclusive_static(
        inclusive: &HashMap<Combination, f64>,
    ) -> Result<HashMap<Combination, f64>, DiagramError> {
        let mut exclusive: HashMap<Combination, f64> = HashMap::new();

        // Sort combinations by size (process from largest to smallest)
        let mut sorted_combos: Vec<_> = inclusive.keys().collect();
        sorted_combos.sort_by_key(|c| std::cmp::Reverse(c.len()));

        for combo in sorted_combos {
            let inclusive_area = inclusive[combo];
            let mut exclusive_area = inclusive_area;

            // Subtract exclusive areas of all proper supersets (combinations that contain this one)
            for (other_combo, &other_excl) in exclusive.iter() {
                if other_combo != combo && other_combo.contains_all(combo) {
                    exclusive_area -= other_excl;
                }
            }

            if exclusive_area < -1e-10 {
                return Err(DiagramError::InvalidValue {
                    combination: combo.to_string(),
                    value: exclusive_area,
                });
            }

            exclusive.insert(combo.clone(), exclusive_area.max(0.0));
        }

        Ok(exclusive)
    }
}

/// Preprocessed specification ready for fitting (internal).
///
/// This is created by filtering out empty sets from a DiagramSpec and
/// computing additional metadata needed for optimization.
#[allow(dead_code)] // Some fields reserved for future use
/// Preprocessed specification for diagram fitting (internal).
#[derive(Clone)]
pub(crate) struct PreprocessedSpec {
    /// Non-empty set names in canonical order
    pub(crate) set_names: Vec<String>,

    /// Mapping from set name to index in set_names
    pub(crate) set_to_idx: HashMap<String, usize>,

    /// All non-empty combinations with their exclusive areas (internal RegionMask format)
    pub(crate) exclusive_areas: HashMap<crate::geometry::diagram::RegionMask, f64>,

    /// All non-empty combinations with their inclusive areas (internal RegionMask format)
    pub(crate) inclusive_areas: HashMap<crate::geometry::diagram::RegionMask, f64>,

    /// Number of non-empty sets
    pub(crate) n_sets: usize,

    /// Areas for each set (for shape sizing)
    pub(crate) set_areas: Vec<f64>,

    /// Pairwise relationships
    pub(crate) relationships: PairwiseRelations,
}

/// Pairwise relationships between sets (internal).
#[derive(Clone)]
pub(crate) struct PairwiseRelations {
    /// Number of sets
    #[allow(dead_code)]
    pub(crate) n_sets: usize,

    /// subset[i][j] = true if set j ⊆ set i
    pub(crate) subset: Vec<Vec<bool>>,

    /// disjoint[i][j] = true if sets i and j are disjoint
    pub(crate) disjoint: Vec<Vec<bool>>,

    /// Desired overlap areas between pairs [i][j]
    pub(crate) overlap_areas: Vec<Vec<f64>>,
}

impl PairwiseRelations {
    /// Check if set j is a subset of set i.
    #[allow(dead_code)]
    pub(crate) fn is_subset(&self, i: usize, j: usize) -> bool {
        self.subset[i][j]
    }

    /// Check if sets i and j are disjoint.
    #[allow(dead_code)]
    pub(crate) fn is_disjoint(&self, i: usize, j: usize) -> bool {
        self.disjoint[i][j]
    }

    /// Get the desired overlap area between sets i and j.
    #[allow(dead_code)]
    pub(crate) fn overlap_area(&self, i: usize, j: usize) -> f64 {
        self.overlap_areas[i][j]
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_both_representations_available() {
        let spec = DiagramSpecBuilder::new()
            .set("A", 10.0)
            .set("B", 8.0)
            .intersection(&["A", "B"], 2.0)
            .input_type(InputType::Inclusive)
            .build()
            .unwrap();

        // Check inclusive areas (what we input)
        assert_eq!(spec.get_inclusive(&Combination::new(&["A"])), Some(10.0));
        assert_eq!(spec.get_inclusive(&Combination::new(&["B"])), Some(8.0));
        assert_eq!(
            spec.get_inclusive(&Combination::new(&["A", "B"])),
            Some(2.0)
        );

        // Check exclusive areas (computed)
        assert_eq!(spec.get_exclusive(&Combination::new(&["A"])), Some(8.0)); // 10 - 2
        assert_eq!(spec.get_exclusive(&Combination::new(&["B"])), Some(6.0)); // 8 - 2
        assert_eq!(
            spec.get_exclusive(&Combination::new(&["A", "B"])),
            Some(2.0)
        );
    }

    #[test]
    fn test_inclusive_three_set_decomposition() {
        // Known 3-set case: A=10, B=8, C=6; A∩B=3, A∩C=2, B∩C=1, A∩B∩C=0
        // Expected exclusive decomposition via inclusion-exclusion:
        //   A∩B∩C (exclusive)           = 0
        //   A∩B only = 3 - 0            = 3
        //   A∩C only = 2 - 0            = 2
        //   B∩C only = 1 - 0            = 1
        //   A only   = 10 - 3 - 2 + 0   = 5
        //   B only   = 8  - 3 - 1 + 0   = 4
        //   C only   = 6  - 2 - 1 + 0   = 3
        let spec = DiagramSpecBuilder::new()
            .set("A", 10.0)
            .set("B", 8.0)
            .set("C", 6.0)
            .intersection(&["A", "B"], 3.0)
            .intersection(&["A", "C"], 2.0)
            .intersection(&["B", "C"], 1.0)
            .intersection(&["A", "B", "C"], 0.0)
            .input_type(InputType::Inclusive)
            .build()
            .unwrap();

        let g = |names: &[&str]| {
            spec.get_exclusive(&Combination::new(names))
                .expect("exclusive area should be defined")
        };
        assert!((g(&["A"]) - 5.0).abs() < 1e-10);
        assert!((g(&["B"]) - 4.0).abs() < 1e-10);
        assert!((g(&["C"]) - 3.0).abs() < 1e-10);
        assert!((g(&["A", "B"]) - 3.0).abs() < 1e-10);
        assert!((g(&["A", "C"]) - 2.0).abs() < 1e-10);
        assert!((g(&["B", "C"]) - 1.0).abs() < 1e-10);
    }

    #[test]
    fn test_inclusive_rejects_negative_disjoint_area() {
        // A∩B is larger than either A or B — impossible set relationships.
        // The exclusive decomposition would yield a negative A-only area.
        let result = DiagramSpecBuilder::new()
            .set("A", 5.0)
            .set("B", 5.0)
            .intersection(&["A", "B"], 10.0)
            .input_type(InputType::Inclusive)
            .build();

        assert!(
            matches!(result, Err(DiagramError::InvalidValue { .. })),
            "expected InvalidValue for negative-disjoint inclusive input, got {:?}",
            result
        );
    }

    #[test]
    fn test_exclusive_input() {
        let spec = DiagramSpecBuilder::new()
            .set("A", 5.0) // exclusive A-only
            .set("B", 2.0) // exclusive B-only
            .intersection(&["A", "B"], 1.0) // exclusive overlap
            .input_type(InputType::Exclusive)
            .build()
            .unwrap();

        // Check exclusive areas (what we input)
        assert_eq!(spec.get_exclusive(&Combination::new(&["A"])), Some(5.0));
        assert_eq!(spec.get_exclusive(&Combination::new(&["B"])), Some(2.0));
        assert_eq!(
            spec.get_exclusive(&Combination::new(&["A", "B"])),
            Some(1.0)
        );

        // Check inclusive areas (computed)
        assert_eq!(spec.get_inclusive(&Combination::new(&["A"])), Some(6.0)); // 5 + 1
        assert_eq!(spec.get_inclusive(&Combination::new(&["B"])), Some(3.0)); // 2 + 1
        assert_eq!(
            spec.get_inclusive(&Combination::new(&["A", "B"])),
            Some(1.0)
        );
    }
}