Skip to main content

organism_planning/
shape_hypothesis.rs

1//! Shape-as-hypothesis — the collaboration shape itself is an object of learning.
2//!
3//! Multiple candidate shapes compete for the same intent. Each shape is
4//! evaluated by evidence quality, convergence speed, or a balanced metric.
5//! The winner is selected, and the learning layer calibrates priors about
6//! which shapes work for which problem classes.
7//!
8//! Over time the system discovers collaboration patterns that no human
9//! would design. Because learning feeds into planning priors (never
10//! authority), the governance layer catches anything that shouldn't land.
11
12use organism_intent::{IntentPacket, Reversibility};
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16use crate::collaboration::{CollaborationCharter, CollaborationTopology};
17
18/// A candidate collaboration shape being tested.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct ShapeCandidate {
21    pub id: Uuid,
22    pub charter: CollaborationCharter,
23    pub rationale: String,
24    pub prior_score: f64,
25    pub evidence_quality: Option<f64>,
26}
27
28/// A competition between multiple candidate shapes.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct ShapeCompetition {
31    pub intent_id: Uuid,
32    pub candidates: Vec<ShapeCandidate>,
33    pub evaluation_metric: ShapeMetric,
34    pub winner: Option<Uuid>,
35}
36
37/// What metric determines shape quality.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum ShapeMetric {
41    EvidenceQuality,
42    ConvergenceSpeed,
43    ContradictionMinimization,
44    Balanced,
45}
46
47/// Observation from a completed shape trial.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct ShapeObservation {
50    pub candidate_id: Uuid,
51    pub hypothesis_count: usize,
52    pub avg_confidence: f64,
53    pub contradiction_rate: f64,
54    pub cycles_to_stability: u32,
55    pub budget_used_fraction: f64,
56}
57
58/// Historical calibration of shape performance for a problem class.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ShapeCalibration {
61    pub problem_class: String,
62    pub topology: CollaborationTopology,
63    pub prior_score: f64,
64    pub posterior_score: f64,
65    pub observation_count: u32,
66}
67
68/// Classify an intent into a problem class for calibration lookup.
69pub fn classify_problem(intent: &IntentPacket) -> String {
70    let reversibility = match intent.reversibility {
71        Reversibility::Reversible => "reversible",
72        Reversibility::Partial => "partial",
73        Reversibility::Irreversible => "irreversible",
74    };
75
76    let complexity = if intent.constraints.len() >= 4 || intent.forbidden.len() >= 3 {
77        "high"
78    } else if intent.constraints.len() >= 2 || !intent.forbidden.is_empty() {
79        "medium"
80    } else {
81        "low"
82    };
83
84    let authority = if intent.authority.len() >= 3 {
85        "multi_authority"
86    } else if !intent.authority.is_empty() {
87        "single_authority"
88    } else {
89        "no_authority"
90    };
91
92    format!("{reversibility}_{complexity}_{authority}")
93}
94
95/// Generate candidate shapes for an intent.
96///
97/// Always produces at least 2 candidates: the derived charter plus
98/// an alternative that explores a different point on the structure spectrum.
99pub fn generate_candidates(
100    intent: &IntentPacket,
101    now: chrono::DateTime<chrono::Utc>,
102    priors: &[ShapeCalibration],
103) -> Vec<ShapeCandidate> {
104    let derived = crate::charter_derivation::derive_charter(intent, now);
105    let problem_class = classify_problem(intent);
106
107    let mut candidates = vec![ShapeCandidate {
108        id: Uuid::new_v4(),
109        charter: derived.charter.clone(),
110        rationale: format!("Derived from intent: {}", derived.rationale.topology_reason),
111        prior_score: derived.confidence,
112        evidence_quality: None,
113    }];
114
115    // Generate an alternative on the opposite end of the structure spectrum.
116    let alt_topology = opposite_topology(derived.charter.topology);
117    let alt_charter = match alt_topology {
118        CollaborationTopology::Huddle => CollaborationCharter::huddle(),
119        CollaborationTopology::DiscussionGroup => CollaborationCharter::discussion_group(),
120        CollaborationTopology::Panel => CollaborationCharter::panel(),
121        CollaborationTopology::SelfOrganizing => CollaborationCharter::self_organizing(),
122    };
123
124    let alt_prior = priors
125        .iter()
126        .find(|p| p.problem_class == problem_class && p.topology == alt_topology)
127        .map_or(0.3, |p| p.posterior_score);
128
129    candidates.push(ShapeCandidate {
130        id: Uuid::new_v4(),
131        charter: alt_charter,
132        rationale: format!("Alternative: {alt_topology:?} explores the opposite structure point",),
133        prior_score: alt_prior,
134        evidence_quality: None,
135    });
136
137    // If priors suggest a third topology that performed well, include it.
138    let best_prior = priors
139        .iter()
140        .filter(|p| {
141            p.problem_class == problem_class
142                && p.topology != derived.charter.topology
143                && p.topology != alt_topology
144                && p.observation_count >= 2
145        })
146        .max_by(|a, b| {
147            a.posterior_score
148                .partial_cmp(&b.posterior_score)
149                .unwrap_or(std::cmp::Ordering::Equal)
150        });
151
152    if let Some(prior) = best_prior {
153        let prior_charter = match prior.topology {
154            CollaborationTopology::Huddle => CollaborationCharter::huddle(),
155            CollaborationTopology::DiscussionGroup => CollaborationCharter::discussion_group(),
156            CollaborationTopology::Panel => CollaborationCharter::panel(),
157            CollaborationTopology::SelfOrganizing => CollaborationCharter::self_organizing(),
158        };
159        candidates.push(ShapeCandidate {
160            id: Uuid::new_v4(),
161            charter: prior_charter,
162            rationale: format!(
163                "Prior-informed: {:?} scored {:.2} over {} observations for '{}'",
164                prior.topology, prior.posterior_score, prior.observation_count, problem_class
165            ),
166            prior_score: prior.posterior_score,
167            evidence_quality: None,
168        });
169    }
170
171    candidates
172}
173
174fn opposite_topology(topology: CollaborationTopology) -> CollaborationTopology {
175    match topology {
176        CollaborationTopology::SelfOrganizing => CollaborationTopology::Panel,
177        CollaborationTopology::Panel => CollaborationTopology::SelfOrganizing,
178        CollaborationTopology::Huddle => CollaborationTopology::DiscussionGroup,
179        CollaborationTopology::DiscussionGroup => CollaborationTopology::Huddle,
180    }
181}
182
183/// Score a shape observation against the chosen metric. Returns [0.0, 1.0].
184#[allow(clippy::cast_precision_loss)]
185pub fn score_observation(observation: &ShapeObservation, metric: ShapeMetric) -> f64 {
186    match metric {
187        ShapeMetric::EvidenceQuality => {
188            let quantity = (observation.hypothesis_count as f64 / 50.0).min(1.0);
189            let quality = observation.avg_confidence.clamp(0.0, 1.0);
190            (quantity * 0.4 + quality * 0.6).clamp(0.0, 1.0)
191        }
192        ShapeMetric::ConvergenceSpeed => {
193            let speed = 1.0 - (f64::from(observation.cycles_to_stability) / 20.0).min(1.0);
194            let efficiency = 1.0 - observation.budget_used_fraction.clamp(0.0, 1.0);
195            (speed * 0.7 + efficiency * 0.3).clamp(0.0, 1.0)
196        }
197        ShapeMetric::ContradictionMinimization => {
198            (1.0 - observation.contradiction_rate.clamp(0.0, 1.0)).clamp(0.0, 1.0)
199        }
200        ShapeMetric::Balanced => {
201            let evidence = score_observation(observation, ShapeMetric::EvidenceQuality);
202            let speed = score_observation(observation, ShapeMetric::ConvergenceSpeed);
203            let contradictions =
204                score_observation(observation, ShapeMetric::ContradictionMinimization);
205            (evidence * 0.4 + speed * 0.3 + contradictions * 0.3).clamp(0.0, 1.0)
206        }
207    }
208}
209
210/// Select the winner from completed observations.
211pub fn select_winner(
212    competition: &ShapeCompetition,
213    observations: &[ShapeObservation],
214) -> Option<Uuid> {
215    if observations.is_empty() {
216        return None;
217    }
218
219    observations
220        .iter()
221        .filter(|obs| {
222            competition
223                .candidates
224                .iter()
225                .any(|c| c.id == obs.candidate_id)
226        })
227        .max_by(|a, b| {
228            let score_a = score_observation(a, competition.evaluation_metric);
229            let score_b = score_observation(b, competition.evaluation_metric);
230            score_a
231                .partial_cmp(&score_b)
232                .unwrap_or(std::cmp::Ordering::Equal)
233        })
234        .map(|obs| obs.candidate_id)
235}
236
237/// Calibrate shape priors from an observation.
238/// Feeds into planning priors, NEVER into authority.
239pub fn calibrate_shape(
240    problem_class: &str,
241    topology: CollaborationTopology,
242    score: f64,
243    existing: &[ShapeCalibration],
244) -> ShapeCalibration {
245    let prior = existing
246        .iter()
247        .find(|c| c.problem_class == problem_class && c.topology == topology);
248
249    let (prior_score, evidence) = match prior {
250        Some(p) => (p.posterior_score, p.observation_count),
251        None => (0.5, 0),
252    };
253
254    let observation_weight = 1.0 / (f64::from(evidence) + 2.0);
255    let posterior = prior_score * (1.0 - observation_weight) + score * observation_weight;
256
257    ShapeCalibration {
258        problem_class: problem_class.to_string(),
259        topology,
260        prior_score,
261        posterior_score: posterior.clamp(0.0, 1.0),
262        observation_count: evidence + 1,
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use chrono::{Duration, Utc};
270
271    fn test_intent() -> IntentPacket {
272        let now = Utc::now();
273        IntentPacket::new("Test", now + Duration::days(7))
274    }
275
276    #[test]
277    fn generate_candidates_produces_at_least_two() {
278        let intent = test_intent();
279        let candidates = generate_candidates(&intent, Utc::now(), &[]);
280        assert!(candidates.len() >= 2);
281    }
282
283    #[test]
284    fn generate_candidates_includes_prior_informed_third() {
285        let intent = test_intent();
286        let problem_class = classify_problem(&intent);
287        let priors = vec![ShapeCalibration {
288            problem_class,
289            topology: CollaborationTopology::Huddle,
290            prior_score: 0.5,
291            posterior_score: 0.8,
292            observation_count: 5,
293        }];
294
295        let candidates = generate_candidates(&intent, Utc::now(), &priors);
296        assert!(candidates.len() >= 3);
297        assert!(
298            candidates
299                .iter()
300                .any(|c| c.rationale.contains("Prior-informed"))
301        );
302    }
303
304    #[test]
305    fn score_observation_evidence_quality() {
306        let obs = ShapeObservation {
307            candidate_id: Uuid::new_v4(),
308            hypothesis_count: 50,
309            avg_confidence: 0.9,
310            contradiction_rate: 0.1,
311            cycles_to_stability: 5,
312            budget_used_fraction: 0.5,
313        };
314
315        let score = score_observation(&obs, ShapeMetric::EvidenceQuality);
316        assert!(score > 0.7);
317        assert!(score <= 1.0);
318    }
319
320    #[test]
321    fn score_observation_convergence_speed() {
322        let fast = ShapeObservation {
323            candidate_id: Uuid::new_v4(),
324            hypothesis_count: 10,
325            avg_confidence: 0.5,
326            contradiction_rate: 0.0,
327            cycles_to_stability: 2,
328            budget_used_fraction: 0.2,
329        };
330        let slow = ShapeObservation {
331            candidate_id: Uuid::new_v4(),
332            hypothesis_count: 10,
333            avg_confidence: 0.5,
334            contradiction_rate: 0.0,
335            cycles_to_stability: 18,
336            budget_used_fraction: 0.9,
337        };
338
339        assert!(
340            score_observation(&fast, ShapeMetric::ConvergenceSpeed)
341                > score_observation(&slow, ShapeMetric::ConvergenceSpeed)
342        );
343    }
344
345    #[test]
346    fn select_winner_picks_highest_score() {
347        let id_a = Uuid::new_v4();
348        let id_b = Uuid::new_v4();
349
350        let competition = ShapeCompetition {
351            intent_id: Uuid::new_v4(),
352            candidates: vec![
353                ShapeCandidate {
354                    id: id_a,
355                    charter: CollaborationCharter::huddle(),
356                    rationale: "A".into(),
357                    prior_score: 0.5,
358                    evidence_quality: None,
359                },
360                ShapeCandidate {
361                    id: id_b,
362                    charter: CollaborationCharter::panel(),
363                    rationale: "B".into(),
364                    prior_score: 0.5,
365                    evidence_quality: None,
366                },
367            ],
368            evaluation_metric: ShapeMetric::EvidenceQuality,
369            winner: None,
370        };
371
372        let observations = vec![
373            ShapeObservation {
374                candidate_id: id_a,
375                hypothesis_count: 10,
376                avg_confidence: 0.5,
377                contradiction_rate: 0.2,
378                cycles_to_stability: 5,
379                budget_used_fraction: 0.5,
380            },
381            ShapeObservation {
382                candidate_id: id_b,
383                hypothesis_count: 40,
384                avg_confidence: 0.9,
385                contradiction_rate: 0.05,
386                cycles_to_stability: 3,
387                budget_used_fraction: 0.4,
388            },
389        ];
390
391        let winner = select_winner(&competition, &observations);
392        assert_eq!(winner, Some(id_b));
393    }
394
395    #[test]
396    fn classify_problem_consistent() {
397        let intent = test_intent();
398        let class1 = classify_problem(&intent);
399        let class2 = classify_problem(&intent);
400        assert_eq!(class1, class2);
401    }
402
403    #[test]
404    fn classify_problem_varies_with_reversibility() {
405        let now = Utc::now();
406        let mut reversible = IntentPacket::new("A", now + Duration::days(7));
407        reversible.reversibility = Reversibility::Reversible;
408
409        let mut irreversible = IntentPacket::new("B", now + Duration::days(7));
410        irreversible.reversibility = Reversibility::Irreversible;
411
412        assert_ne!(
413            classify_problem(&reversible),
414            classify_problem(&irreversible)
415        );
416    }
417
418    #[test]
419    fn calibrate_shape_from_scratch() {
420        let cal = calibrate_shape("test_class", CollaborationTopology::Huddle, 0.8, &[]);
421
422        assert_eq!(cal.problem_class, "test_class");
423        assert_eq!(cal.topology, CollaborationTopology::Huddle);
424        assert!((cal.prior_score - 0.5).abs() < f64::EPSILON);
425        assert_eq!(cal.observation_count, 1);
426        assert!(cal.posterior_score > 0.5);
427        assert!(cal.posterior_score < 0.8);
428    }
429
430    #[test]
431    fn calibrate_shape_converges() {
432        let mut calibrations: Vec<ShapeCalibration> = vec![];
433        for _ in 0..10 {
434            let cal = calibrate_shape("test", CollaborationTopology::Panel, 0.9, &calibrations);
435            calibrations = vec![cal];
436        }
437
438        assert!(calibrations[0].posterior_score > 0.75);
439        assert_eq!(calibrations[0].observation_count, 10);
440    }
441
442    // ── Negative tests ────────────────────────────────────────────
443
444    #[test]
445    fn select_winner_empty_observations() {
446        let competition = ShapeCompetition {
447            intent_id: Uuid::new_v4(),
448            candidates: vec![],
449            evaluation_metric: ShapeMetric::Balanced,
450            winner: None,
451        };
452        assert!(select_winner(&competition, &[]).is_none());
453    }
454
455    #[test]
456    fn score_zero_hypotheses() {
457        let obs = ShapeObservation {
458            candidate_id: Uuid::new_v4(),
459            hypothesis_count: 0,
460            avg_confidence: 0.0,
461            contradiction_rate: 0.0,
462            cycles_to_stability: 0,
463            budget_used_fraction: 0.0,
464        };
465        let score = score_observation(&obs, ShapeMetric::Balanced);
466        assert!(score >= 0.0);
467        assert!(score <= 1.0);
468    }
469
470    #[test]
471    fn score_extreme_values() {
472        let obs = ShapeObservation {
473            candidate_id: Uuid::new_v4(),
474            hypothesis_count: 10_000,
475            avg_confidence: 10.0,    // out of range, should clamp
476            contradiction_rate: 5.0, // out of range
477            cycles_to_stability: 1000,
478            budget_used_fraction: 2.0, // out of range
479        };
480
481        for metric in [
482            ShapeMetric::EvidenceQuality,
483            ShapeMetric::ConvergenceSpeed,
484            ShapeMetric::ContradictionMinimization,
485            ShapeMetric::Balanced,
486        ] {
487            let score = score_observation(&obs, metric);
488            assert!(score >= 0.0, "metric {metric:?} score {score} < 0");
489            assert!(score <= 1.0, "metric {metric:?} score {score} > 1");
490        }
491    }
492
493    #[test]
494    fn generate_candidates_with_empty_priors() {
495        let intent = test_intent();
496        let candidates = generate_candidates(&intent, Utc::now(), &[]);
497        assert!(candidates.len() >= 2);
498        // No third candidate since no priors
499        assert_eq!(candidates.len(), 2);
500    }
501
502    // ── Proptests ─────────────────────────────────────────────────
503
504    #[allow(clippy::cast_precision_loss)]
505    mod proptests {
506        use super::*;
507        use proptest::prelude::*;
508
509        proptest! {
510            #[test]
511            fn score_always_bounded(
512                hyp in 0_usize..200,
513                conf in 0.0..=2.0_f64,
514                contra in 0.0..=2.0_f64,
515                cycles in 0_u32..100,
516                budget in 0.0..=2.0_f64,
517            ) {
518                let obs = ShapeObservation {
519                    candidate_id: Uuid::new_v4(),
520                    hypothesis_count: hyp,
521                    avg_confidence: conf,
522                    contradiction_rate: contra,
523                    cycles_to_stability: cycles,
524                    budget_used_fraction: budget,
525                };
526
527                for metric in [
528                    ShapeMetric::EvidenceQuality,
529                    ShapeMetric::ConvergenceSpeed,
530                    ShapeMetric::ContradictionMinimization,
531                    ShapeMetric::Balanced,
532                ] {
533                    let score = score_observation(&obs, metric);
534                    prop_assert!((0.0..=1.0).contains(&score), "metric {metric:?} score {score}");
535                }
536            }
537
538            #[test]
539            fn calibrate_posterior_bounded(
540                score in 0.0..=1.0_f64,
541                prior_score in 0.0..=1.0_f64,
542                evidence in 0_u32..100,
543            ) {
544                let existing = vec![ShapeCalibration {
545                    problem_class: "test".into(),
546                    topology: CollaborationTopology::Huddle,
547                    prior_score,
548                    posterior_score: prior_score,
549                    observation_count: evidence,
550                }];
551
552                let cal = calibrate_shape("test", CollaborationTopology::Huddle, score, &existing);
553                prop_assert!(cal.posterior_score >= 0.0);
554                prop_assert!(cal.posterior_score <= 1.0);
555                prop_assert_eq!(cal.observation_count, evidence + 1);
556            }
557
558            #[test]
559            fn calibrate_converges_toward_observation(
560                score in 0.0..=1.0_f64,
561                rounds in 1_usize..20,
562            ) {
563                let mut cals: Vec<ShapeCalibration> = vec![];
564                for _ in 0..rounds {
565                    let cal = calibrate_shape("test", CollaborationTopology::Huddle, score, &cals);
566                    cals = vec![cal];
567                }
568
569                let posterior = cals[0].posterior_score;
570                let distance = (posterior - score).abs();
571                let initial_distance = (0.5 - score).abs();
572                if rounds >= 3 && initial_distance > 0.05 {
573                    prop_assert!(
574                        distance < initial_distance,
575                        "posterior {posterior} should be closer to score {score} than initial 0.5"
576                    );
577                }
578            }
579        }
580    }
581}