1use std::collections::{BTreeMap, BTreeSet};
2
3use sim_kernel::{ContentId, Datum, Symbol};
4
5use super::{
6 ConclusionId, Explanation, FactId, FederatedClosure,
7 assay_repair::{ProjectionRepairItem, ProjectionRepairSet},
8};
9
10const REQUIRED_DELTAS: [&str; 6] = ["D1", "D2", "D3", "D4", "D5", "D6"];
11
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14pub enum ControlledDeltaClass {
15 NoSemanticChange,
17 PrivateImplementation,
19 PublicApi,
21 PublicHeadsAndPins,
23 ProofAndCheckerImplementation,
25 PresentationOnly,
27}
28
29#[derive(Clone, Debug, Eq, PartialEq)]
31pub struct ControlledDelta {
32 pub id: String,
34 pub class: ControlledDeltaClass,
36 pub changed_facts: BTreeSet<FactId>,
38 pub revision_delta: u64,
40 pub carried_state: bool,
42}
43
44impl ControlledDelta {
45 pub fn new(
47 id: impl Into<String>,
48 class: ControlledDeltaClass,
49 changed_facts: impl IntoIterator<Item = FactId>,
50 revision_delta: u64,
51 carried_state: bool,
52 ) -> Result<Self, AssayError> {
53 let id = id.into();
54 if id.trim().is_empty() {
55 return Err(AssayError::InvalidDeltaId);
56 }
57 let changed_facts = changed_facts.into_iter().collect::<BTreeSet<_>>();
58 if matches!(
59 class,
60 ControlledDeltaClass::NoSemanticChange | ControlledDeltaClass::PresentationOnly
61 ) && !changed_facts.is_empty()
62 {
63 return Err(AssayError::SemanticNoOpCarriesChangedFacts(id));
64 }
65 Ok(Self {
66 id,
67 class,
68 changed_facts,
69 revision_delta,
70 carried_state,
71 })
72 }
73}
74
75#[derive(Clone, Debug, Eq, PartialEq)]
77pub struct ExpectedClosure {
78 pub delta: String,
80 pub denominator: usize,
82 pub max_affected: usize,
84 pub max_revision_delta: u64,
86 pub carried_state: bool,
88 pub affected: BTreeSet<ConclusionId>,
90}
91
92#[derive(Clone, Debug, Eq, PartialEq)]
94pub struct ExpectedClosureSet {
95 source: ContentId,
96 id: ContentId,
97 rows: BTreeMap<String, ExpectedClosure>,
98}
99
100impl ExpectedClosureSet {
101 pub fn freeze(
103 source: ContentId,
104 rows: impl IntoIterator<Item = ExpectedClosure>,
105 ) -> Result<Self, AssayError> {
106 let mut canonical = BTreeMap::new();
107 for row in rows {
108 if canonical.insert(row.delta.clone(), row).is_some() {
109 return Err(AssayError::DuplicateDelta);
110 }
111 }
112 let actual = canonical.keys().map(String::as_str).collect::<Vec<_>>();
113 if actual != REQUIRED_DELTAS {
114 return Err(AssayError::IncompleteDeltaSet(actual.join(",")));
115 }
116 let denominator = canonical["D1"].denominator;
117 for row in canonical.values() {
118 if row.denominator == 0
119 || row.denominator != denominator
120 || row.max_affected > row.denominator
121 || row.affected.len() > row.max_affected
122 {
123 return Err(AssayError::InvalidExpectedClosure {
124 delta: row.delta.clone(),
125 reason: ExpectedClosureViolation::InconsistentDenominatorOrCeiling,
126 });
127 }
128 }
129 let d1 = &canonical["D1"];
130 if !d1.affected.is_empty()
131 || d1.max_affected != 0
132 || d1.max_revision_delta != 0
133 || d1.carried_state
134 {
135 return Err(AssayError::InvalidExpectedClosure {
136 delta: "D1".into(),
137 reason: ExpectedClosureViolation::NoChangeContract,
138 });
139 }
140 let d2 = &canonical["D2"];
141 if unaffected_basis_points(d2.denominator, d2.affected.len()) < 9_500 {
142 return Err(AssayError::InvalidExpectedClosure {
143 delta: "D2".into(),
144 reason: ExpectedClosureViolation::UnaffectedFloor,
145 });
146 }
147 let d4 = &canonical["D4"];
148 if unaffected_basis_points(d4.denominator, d4.affected.len()) < 7_000 {
149 return Err(AssayError::InvalidExpectedClosure {
150 delta: "D4".into(),
151 reason: ExpectedClosureViolation::UnaffectedFloor,
152 });
153 }
154 let d6 = &canonical["D6"];
155 if !d6.affected.is_empty()
156 || d6.max_affected != 0
157 || d6.max_revision_delta > 1
158 || d6.carried_state
159 {
160 return Err(AssayError::InvalidExpectedClosure {
161 delta: "D6".into(),
162 reason: ExpectedClosureViolation::PresentationOnlyContract,
163 });
164 }
165 let datum = Datum::Node {
166 tag: Symbol::qualified("projection", "expected-closure-set-v1"),
167 fields: vec![
168 (Symbol::new("source"), content_id_datum(&source)),
169 (
170 Symbol::new("rows"),
171 Datum::Vector(canonical.values().map(expected_datum).collect()),
172 ),
173 ],
174 };
175 let id = datum
176 .content_id()
177 .map_err(|error| AssayError::Canonical(error.to_string()))?;
178 Ok(Self {
179 source,
180 id,
181 rows: canonical,
182 })
183 }
184
185 #[must_use]
187 pub fn source(&self) -> &ContentId {
188 &self.source
189 }
190
191 #[must_use]
193 pub fn id(&self) -> &ContentId {
194 &self.id
195 }
196}
197
198#[derive(Clone, Debug, Eq, PartialEq)]
200pub struct PredictedWork {
201 pub decisions: usize,
203 pub changed_facts: usize,
205 pub explanations: usize,
207 pub journal_records: usize,
209 pub planned_starts: usize,
211}
212
213#[derive(Clone, Debug, Eq, PartialEq)]
215pub struct PredictedClosureReport {
216 pub delta: String,
218 pub affected: BTreeSet<ConclusionId>,
220 pub unaffected: BTreeSet<ConclusionId>,
222 pub denominator: usize,
224 pub unaffected_basis_points: u16,
226 pub revision_delta: u64,
228 pub carried_state: bool,
230 pub explanations: Vec<Explanation>,
232 pub work: PredictedWork,
234}
235
236#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
238pub enum AssayContract {
239 DeltaClass,
241 UnknownChangedFact,
243 Denominator,
245 AffectedCeiling,
247 RevisionDelta,
249 CarriedState,
251 D1UnaffectedFloor,
253 D2UnaffectedFloor,
255 D4UnaffectedFloor,
257 D6CarriedState,
259 SemanticNoOpWork,
261}
262
263#[derive(Clone, Debug, Eq, PartialEq)]
265pub enum AssayOutcome {
266 Passed(PredictedClosureReport),
268 Repair {
270 report: PredictedClosureReport,
272 repairs: ProjectionRepairSet,
274 },
275}
276
277#[derive(Clone, Debug, Eq, PartialEq)]
279pub struct StageOneQualification {
280 pub oracle: ContentId,
282 pub reports: Vec<PredictedClosureReport>,
284}
285
286pub struct PredictedClosureAssay<'a> {
288 closure: &'a FederatedClosure,
289}
290
291impl<'a> PredictedClosureAssay<'a> {
292 #[must_use]
294 pub const fn new(closure: &'a FederatedClosure) -> Self {
295 Self { closure }
296 }
297
298 pub fn predict(
300 &self,
301 delta: &ControlledDelta,
302 expected: &ExpectedClosureSet,
303 ) -> Result<AssayOutcome, AssayError> {
304 let oracle = expected
305 .rows
306 .get(&delta.id)
307 .ok_or_else(|| AssayError::UnknownDelta(delta.id.clone()))?;
308 let all = self.closure.conclusions().cloned().collect::<BTreeSet<_>>();
309 let affected = self
310 .closure
311 .affected(delta.changed_facts.iter().cloned())
312 .into_iter()
313 .collect::<BTreeSet<_>>();
314 let unaffected = all.difference(&affected).cloned().collect::<BTreeSet<_>>();
315 let explanations = affected
316 .iter()
317 .flat_map(|conclusion| {
318 delta
319 .changed_facts
320 .iter()
321 .filter_map(move |fact| self.closure.explain(conclusion, fact).ok())
322 })
323 .collect::<Vec<_>>();
324 let semantic = !delta.changed_facts.is_empty();
325 let work = PredictedWork {
326 decisions: affected.len(),
327 changed_facts: delta.changed_facts.len(),
328 explanations: explanations.len(),
329 journal_records: usize::from(semantic) * delta.changed_facts.len(),
330 planned_starts: affected.len(),
331 };
332 let basis_points = unaffected_basis_points(all.len(), affected.len());
333 let report = PredictedClosureReport {
334 delta: delta.id.clone(),
335 affected: affected.clone(),
336 unaffected,
337 denominator: all.len(),
338 unaffected_basis_points: basis_points,
339 revision_delta: delta.revision_delta,
340 carried_state: delta.carried_state,
341 explanations,
342 work,
343 };
344 let mut repairs = BTreeSet::new();
345 if expected_delta_class(&delta.id) != Some(delta.class) {
346 repairs.insert(ProjectionRepairItem::Contract(AssayContract::DeltaClass));
347 }
348 if delta
349 .changed_facts
350 .iter()
351 .any(|fact| !self.closure.contains_fact(fact))
352 {
353 repairs.insert(ProjectionRepairItem::Contract(
354 AssayContract::UnknownChangedFact,
355 ));
356 }
357 if oracle.denominator != all.len() {
358 repairs.insert(ProjectionRepairItem::Contract(AssayContract::Denominator));
359 }
360 for conclusion in affected.difference(&oracle.affected) {
361 for fact in &delta.changed_facts {
362 if self.closure.depends_on(conclusion, fact) {
363 repairs.insert(ProjectionRepairItem::GraphEdge {
364 conclusion: conclusion.clone(),
365 fact: fact.clone(),
366 });
367 }
368 }
369 }
370 for conclusion in oracle.affected.difference(&affected) {
371 repairs.insert(ProjectionRepairItem::Declaration(conclusion.clone()));
372 }
373 if affected.len() > oracle.max_affected {
374 repairs.insert(ProjectionRepairItem::Contract(
375 AssayContract::AffectedCeiling,
376 ));
377 }
378 if delta.revision_delta > oracle.max_revision_delta {
379 repairs.insert(ProjectionRepairItem::Contract(AssayContract::RevisionDelta));
380 }
381 if delta.carried_state != oracle.carried_state {
382 repairs.insert(ProjectionRepairItem::Contract(AssayContract::CarriedState));
383 }
384 match delta.id.as_str() {
385 "D1" if report.unaffected_basis_points != 10_000 => {
386 repairs.insert(ProjectionRepairItem::Contract(
387 AssayContract::D1UnaffectedFloor,
388 ));
389 }
390 "D2" if report.unaffected_basis_points < 9_500 => {
391 repairs.insert(ProjectionRepairItem::Contract(
392 AssayContract::D2UnaffectedFloor,
393 ));
394 }
395 "D4" if report.unaffected_basis_points < 7_000 => {
396 repairs.insert(ProjectionRepairItem::Contract(
397 AssayContract::D4UnaffectedFloor,
398 ));
399 }
400 "D6" if report.carried_state => {
401 repairs.insert(ProjectionRepairItem::Contract(
402 AssayContract::D6CarriedState,
403 ));
404 }
405 _ => {}
406 }
407 if !semantic && (report.work.planned_starts != 0 || report.work.journal_records != 0) {
408 repairs.insert(ProjectionRepairItem::Contract(
409 AssayContract::SemanticNoOpWork,
410 ));
411 }
412 if repairs.is_empty() {
413 Ok(AssayOutcome::Passed(report))
414 } else {
415 Ok(AssayOutcome::Repair {
416 report,
417 repairs: ProjectionRepairSet::new(&delta.id, repairs)?,
418 })
419 }
420 }
421
422 pub fn qualify(
424 &self,
425 deltas: &[ControlledDelta],
426 expected: &ExpectedClosureSet,
427 ) -> Result<StageOneQualification, AssayError> {
428 let actual = deltas
429 .iter()
430 .map(|delta| delta.id.as_str())
431 .collect::<Vec<_>>();
432 if actual != REQUIRED_DELTAS {
433 return Err(AssayError::IncompleteDeltaSet(actual.join(",")));
434 }
435 let mut reports = Vec::with_capacity(REQUIRED_DELTAS.len());
436 for delta in deltas {
437 match self.predict(delta, expected)? {
438 AssayOutcome::Passed(report) => reports.push(report),
439 AssayOutcome::Repair { repairs, .. } => {
440 return Err(AssayError::RepairRequired(repairs));
441 }
442 }
443 }
444 Ok(StageOneQualification {
445 oracle: expected.id.clone(),
446 reports,
447 })
448 }
449}
450
451#[derive(Clone, Debug, Eq, PartialEq)]
453pub enum AssayError {
454 InvalidDeltaId,
456 DuplicateDelta,
458 IncompleteDeltaSet(String),
460 UnknownDelta(String),
462 InvalidExpectedClosure {
464 delta: String,
466 reason: ExpectedClosureViolation,
468 },
469 SemanticNoOpCarriesChangedFacts(String),
471 Canonical(String),
473 RepairRequired(ProjectionRepairSet),
475}
476
477#[derive(Clone, Copy, Debug, Eq, PartialEq)]
479pub enum ExpectedClosureViolation {
480 InconsistentDenominatorOrCeiling,
482 NoChangeContract,
484 UnaffectedFloor,
486 PresentationOnlyContract,
488}
489
490impl std::fmt::Display for AssayError {
491 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
492 write!(formatter, "{self:?}")
493 }
494}
495
496impl std::error::Error for AssayError {}
497
498fn content_id_datum(id: &ContentId) -> Datum {
499 Datum::Node {
500 tag: Symbol::qualified("core", "content-id-v1"),
501 fields: vec![
502 (
503 Symbol::new("algorithm"),
504 Datum::Symbol(id.algorithm.clone()),
505 ),
506 (Symbol::new("digest"), Datum::Bytes(id.bytes.to_vec())),
507 ],
508 }
509}
510
511fn expected_delta_class(delta: &str) -> Option<ControlledDeltaClass> {
512 match delta {
513 "D1" => Some(ControlledDeltaClass::NoSemanticChange),
514 "D2" => Some(ControlledDeltaClass::PrivateImplementation),
515 "D3" => Some(ControlledDeltaClass::PublicApi),
516 "D4" => Some(ControlledDeltaClass::PublicHeadsAndPins),
517 "D5" => Some(ControlledDeltaClass::ProofAndCheckerImplementation),
518 "D6" => Some(ControlledDeltaClass::PresentationOnly),
519 _ => None,
520 }
521}
522
523fn unaffected_basis_points(denominator: usize, affected: usize) -> u16 {
524 if denominator == 0 || affected > denominator {
525 return 0;
526 }
527 u16::try_from((denominator - affected).saturating_mul(10_000) / denominator).unwrap_or(10_000)
528}
529
530fn expected_datum(row: &ExpectedClosure) -> Datum {
531 Datum::Node {
532 tag: Symbol::qualified("projection", "expected-closure-v1"),
533 fields: vec![
534 (Symbol::new("delta"), Datum::String(row.delta.clone())),
535 (
536 Symbol::new("denominator"),
537 Datum::String(row.denominator.to_string()),
538 ),
539 (
540 Symbol::new("max-affected"),
541 Datum::String(row.max_affected.to_string()),
542 ),
543 (
544 Symbol::new("max-revision-delta"),
545 Datum::String(row.max_revision_delta.to_string()),
546 ),
547 (Symbol::new("carried-state"), Datum::Bool(row.carried_state)),
548 (
549 Symbol::new("affected"),
550 Datum::Vector(
551 row.affected
552 .iter()
553 .map(|id| Datum::String(id.as_str().to_owned()))
554 .collect(),
555 ),
556 ),
557 ],
558 }
559}