1use organism_intent::{IntentPacket, Reversibility};
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16use crate::collaboration::{CollaborationCharter, CollaborationTopology};
17
18#[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#[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#[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#[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#[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
68pub 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
95pub 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 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 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#[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
210pub 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
237pub 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 #[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, contradiction_rate: 5.0, cycles_to_stability: 1000,
478 budget_used_fraction: 2.0, };
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 assert_eq!(candidates.len(), 2);
500 }
501
502 #[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}