1use std::collections::BTreeMap;
35use std::path::Path;
36
37use serde::Serialize;
38
39use crate::Engine;
40use crate::anchor::{AnchorGrain, AnchorProvenanceClass, AnchorState};
41use crate::binding::{BindingV1, CoverageSemantics, MediumCapabilities, medium_capabilities};
42use crate::chunking::estimate_tokens;
43
44use super::advance::read_advance_store;
45use super::cursor::{enumerate_facet_files, source_moved};
46use super::findings::{FindingClass, FindingKey, read_findings_store};
47use super::resolve::{ChangeStrategy, ResolvedIngest, ResolvedSource, resolve_change_strategy};
48
49pub const DEFAULT_REPORT_BUDGET: usize = 8_000;
53
54pub const ALLOWED_REPORT_INCLUDE_KEYS: &[&str] =
60 &["uncovered_artifacts", "tree_fanout", "superseded_findings"];
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
73#[serde(tag = "kind", rename_all = "kebab-case")]
74pub enum DenominatorBasis {
75 Enumerated {
78 count: usize,
80 },
81 NonEnumerable {
85 reason: String,
87 },
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
94pub struct TreeFanout {
95 pub entity: String,
97 pub artifact: String,
99 pub fanout: usize,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
107pub struct GrainCoverage {
108 pub denominator: DenominatorBasis,
110 pub direct_covered: usize,
112 pub tree_only_covered: usize,
115 pub uncovered: Vec<String>,
117 pub tree_anchors: Vec<TreeFanout>,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)]
125pub struct AnchorComposition {
126 pub by_class: BTreeMap<String, usize>,
129 pub by_grain: BTreeMap<String, usize>,
131 pub authored: usize,
134 pub observed: usize,
136 pub resolves: usize,
138 pub drifted: usize,
140 pub recheck: usize,
142 pub orphaned: usize,
144 pub unobserved: usize,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
152pub struct FacetCapability {
153 pub facet: String,
155 pub medium_type: String,
157 pub enumerable: bool,
159 pub change_signal: bool,
161 pub base_version_retrievable: bool,
163 pub anchor_namespace: String,
165 pub signal: String,
168}
169
170impl FacetCapability {
171 fn from_caps(
172 facet: String,
173 medium_type: String,
174 caps: MediumCapabilities,
175 strategy: ChangeStrategy,
176 ) -> Self {
177 FacetCapability {
178 facet,
179 medium_type,
180 enumerable: caps.enumerable,
181 change_signal: caps.change_signal,
182 base_version_retrievable: caps.base_version_retrievable
189 && strategy_retrieves_base(strategy),
190 anchor_namespace: caps.anchor_namespace.to_string(),
191 signal: signal_wire(strategy).to_string(),
192 }
193 }
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
198pub struct FacetFreshness {
199 pub facet: String,
201 pub signal: String,
203 pub synced: Option<String>,
205 pub verified: Option<String>,
207 pub change_detectable: bool,
212}
213
214#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
216pub struct FidelityReport {
217 pub binding: String,
219 pub destination_mem: String,
221 pub adopt: bool,
228 pub coverage_semantics: CoverageSemantics,
230 pub capabilities: Vec<FacetCapability>,
232 pub freshness: Vec<FacetFreshness>,
234 pub source_moved_past_synced: Option<bool>,
238 pub coverage: GrainCoverage,
240 pub anchors: AnchorComposition,
242 pub findings_by_class: BTreeMap<String, usize>,
244 pub backlog: usize,
246 pub superseded: Vec<String>,
249 pub disposed_excluded: usize,
252 pub disposed_excluded_rationales: Vec<(String, String)>,
258 pub degradations: Vec<String>,
260}
261
262#[derive(Debug, Clone, PartialEq, Eq)]
269pub struct RenderedFidelityReport {
270 pub markdown: String,
272 pub mode: String,
275 pub hints: Vec<(String, usize)>,
278 pub budget_used: usize,
280}
281
282fn ratio(num: usize, den: usize) -> String {
288 if den == 0 {
289 format!("{num}/{den} (n/a)")
290 } else {
291 let pct = (num as f64) * 100.0 / (den as f64);
292 format!("{num}/{den} ({pct:.1}%)")
293 }
294}
295
296fn render_hard_required(report: &FidelityReport) -> String {
300 let mut md = String::new();
301 md.push_str(&format!("# Fidelity report — `{}`\n\n", report.binding));
302 md.push_str(&format!(
303 "- **Destination mem:** `{}`\n- **Coverage semantics:** {}\n\n",
304 report.destination_mem,
305 match report.coverage_semantics {
306 CoverageSemantics::Exhaustive => "exhaustive",
307 CoverageSemantics::Curated => "curated",
308 }
309 ));
310
311 if report.adopt {
318 md.push_str("## Adopting — first verify\n\n");
319 md.push_str(
320 "This mem predates its binding: it carries no anchors and has no prior sync \
321 baseline, so **0% anchored is expected — this is onboarding, not a failure.** \
322 Do not read the coverage numbers below as drift or a red verdict; the uncovered \
323 artifacts are the backfill worklist, not defects.\n\n",
324 );
325 md.push_str(&format!(
326 "**Backfill path:** run `memstead projection sync {}` to work through the in-scope \
327 source artifacts that carry no entity yet, covering the clearly-new concepts among \
328 them through the normal mutation surface. Backfilling is incremental — a partial \
329 pass is fine, and the next sync continues where you left off.\n\n",
330 report.binding
331 ));
332 }
333
334 md.push_str("## Denominator provenance\n\n");
336 match &report.coverage.denominator {
337 DenominatorBasis::Enumerated { count } => md.push_str(&format!(
338 "Coverage is reported relative to the per-medium enumeration `S(D)` = **{count}** \
339 source artifact(s) in scope (after `deny_paths`).\n\n"
340 )),
341 DenominatorBasis::NonEnumerable { reason } => md.push_str(&format!(
342 "No `S(D)` denominator: {reason}. Coverage is reported over anchors only; the \
343 per-medium enumeration is unavailable.\n\n"
344 )),
345 }
346
347 md.push_str("## Capability matrix\n\n");
349 if report.capabilities.is_empty() {
350 md.push_str("_(no primary sources resolved)_\n\n");
351 } else {
352 for c in &report.capabilities {
353 md.push_str(&format!("### `{}` ({})\n\n", c.facet, c.medium_type));
354 md.push_str(&format!(
355 "- enumerable: {} | change_signal: {} | base_version_retrievable: {}\n",
356 c.enumerable, c.change_signal, c.base_version_retrievable
357 ));
358 md.push_str(&format!(
359 "- anchor_namespace: `{}` | resolved signal: `{}`\n\n",
360 c.anchor_namespace, c.signal
361 ));
362 }
363 }
364
365 md.push_str("## Freshness\n\n");
367 if report.freshness.is_empty() {
368 md.push_str("_(no source facets)_\n\n");
369 } else {
370 for f in &report.freshness {
371 md.push_str(&format!("### `{}`\n\n", f.facet));
372 md.push_str(&format!("- signal: `{}`\n", f.signal));
373 if !f.change_detectable {
374 md.push_str(
378 "- **freshness unknowable** — this medium is not change-detectable \
379 (no change signal); `#synced` / `#verified` cannot be adjudicated as fresh\n",
380 );
381 } else {
382 match &f.synced {
383 Some(t) => md.push_str(&format!("- `#synced`: `{t}`\n")),
384 None => md.push_str("- `#synced`: never synced\n"),
385 }
386 match &f.verified {
387 Some(t) => md.push_str(&format!("- `#verified`: `{t}`\n")),
388 None => md.push_str("- `#verified`: never verified\n"),
389 }
390 }
391 md.push('\n');
392 }
393 match report.source_moved_past_synced {
395 Some(true) => md.push_str(
396 "**Source moved past its `#synced` baseline** — the graph is stale for the \
397 moved facet(s); a sync pass is due.\n\n",
398 ),
399 Some(false) => {
400 md.push_str("Every change-detectable source is at its `#synced` baseline.\n\n")
401 }
402 None => {}
403 }
404 }
405
406 md.push_str("## Coverage (grain-classed)\n\n");
408 let den = match &report.coverage.denominator {
409 DenominatorBasis::Enumerated { count } => *count,
410 DenominatorBasis::NonEnumerable { .. } => 0,
411 };
412 md.push_str(&format!(
413 "- direct-covered (file / span anchors): {}\n",
414 ratio(report.coverage.direct_covered, den)
415 ));
416 let tree_files: usize = report.coverage.tree_anchors.iter().map(|t| t.fanout).sum();
419 md.push_str(&format!(
420 "- tree-anchor fan-out (separate axis): {} tree anchor(s) fanning out over {} file(s); \
421 {} file(s) covered ONLY via a tree anchor\n",
422 report.coverage.tree_anchors.len(),
423 tree_files,
424 report.coverage.tree_only_covered
425 ));
426 md.push_str(&format!(
427 "- uncovered (no anchor): {}\n\n",
428 report.coverage.uncovered.len()
429 ));
430
431 match report.coverage_semantics {
436 CoverageSemantics::Exhaustive if report.adopt => {
437 let backlog = report
438 .coverage
439 .uncovered
440 .len()
441 .saturating_sub(report.disposed_excluded);
442 md.push_str(&format!(
443 "**Exhaustive coverage (onboarding):** {backlog} in-scope artifact(s) carry no \
444 entity yet ({} disposed excluded) — the expected first-sync backfill worklist \
445 for a mem that predates its binding, not defects.\n\n",
446 report.disposed_excluded
447 ));
448 }
449 CoverageSemantics::Exhaustive => {
450 let findings = report
451 .coverage
452 .uncovered
453 .len()
454 .saturating_sub(report.disposed_excluded);
455 md.push_str(&format!(
456 "**Exhaustive coverage:** {findings} unaccounted artifact(s) — not anchored, not \
457 declared-excluded, no persisted disposition ({} disposed excluded) — are \
458 **findings**.\n\n",
459 report.disposed_excluded
460 ));
461 }
462 CoverageSemantics::Curated => {
463 md.push_str(&format!(
464 "**Curated coverage:** {} unaccounted artifact(s) are **information**, not \
465 defects — a curated binding covers a deliberate slice.\n\n",
466 report.coverage.uncovered.len()
467 ));
468 }
469 }
470
471 if !report.disposed_excluded_rationales.is_empty() {
475 md.push_str("**Excluded on purpose (persisted dispositions):**\n");
476 for (artifact, rationale) in &report.disposed_excluded_rationales {
477 if rationale.is_empty() {
478 md.push_str(&format!("- `{artifact}`\n"));
479 } else {
480 md.push_str(&format!("- `{artifact}` — {rationale}\n"));
481 }
482 }
483 md.push('\n');
484 }
485
486 md.push_str("## Anchors\n\n");
488 md.push_str(&format!(
489 "- by class: {}\n",
490 render_counts(&report.anchors.by_class)
491 ));
492 md.push_str(&format!(
493 "- by grain: {}\n",
494 render_counts(&report.anchors.by_grain)
495 ));
496 md.push_str(&format!(
497 "- `authored` bucket (excluded from coverage/accuracy denominators): {}\n",
498 report.anchors.authored
499 ));
500 md.push_str(&format!(
501 "- resolution (non-`authored`, observed): resolves {}, drifted {}, recheck {}, orphaned {}\n",
502 report.anchors.resolves,
503 report.anchors.drifted,
504 report.anchors.recheck,
505 report.anchors.orphaned
506 ));
507 md.push_str(&format!(
508 "- **anchor-resolution %:** {}\n",
509 ratio(report.anchors.resolves, report.anchors.observed)
510 ));
511 md.push_str(&format!(
512 "- unobserved this pass (state unavailable, never scored as resolved): {}\n\n",
513 report.anchors.unobserved
514 ));
515
516 md.push_str("## Findings\n\n");
518 md.push_str(&format!(
519 "- by class: {}\n",
520 render_counts(&report.findings_by_class)
521 ));
522 md.push_str(&format!(
523 "- **tier-3 adjudication backlog:** {}\n",
524 report.backlog
525 ));
526 md.push_str(&format!(
527 "- superseded (prior `(hash(D), source_head)` key, segregated): {}\n\n",
528 report.superseded.len()
529 ));
530
531 md.push_str("## Degradations\n\n");
533 if report.degradations.is_empty() {
534 md.push_str("_(none)_\n\n");
535 } else {
536 for d in &report.degradations {
537 md.push_str(&format!("- {d}\n"));
538 }
539 md.push('\n');
540 }
541
542 md
543}
544
545fn render_counts(counts: &BTreeMap<String, usize>) -> String {
547 if counts.is_empty() {
548 return "(none)".to_string();
549 }
550 counts
551 .iter()
552 .map(|(k, v)| format!("{k}={v}"))
553 .collect::<Vec<_>>()
554 .join(", ")
555}
556
557fn heavy_sections(report: &FidelityReport) -> Vec<(&'static str, String)> {
561 let mut out: Vec<(&'static str, String)> = Vec::new();
562
563 let mut s = String::new();
565 if !report.coverage.uncovered.is_empty() {
566 s.push_str("## Uncovered artifacts\n\n");
567 for a in &report.coverage.uncovered {
568 s.push_str(&format!("- `{a}`\n"));
569 }
570 s.push('\n');
571 }
572 out.push(("uncovered_artifacts", s));
573
574 let mut s = String::new();
576 if !report.coverage.tree_anchors.is_empty() {
577 s.push_str("## Tree-anchor fan-out (detail)\n\n");
578 for t in &report.coverage.tree_anchors {
579 s.push_str(&format!(
580 "- `{}` → `{}` fans out over {} file(s)\n",
581 t.entity, t.artifact, t.fanout
582 ));
583 }
584 s.push('\n');
585 }
586 out.push(("tree_fanout", s));
587
588 let mut s = String::new();
590 if !report.superseded.is_empty() {
591 s.push_str("## Superseded findings (detail)\n\n");
592 for f in &report.superseded {
593 s.push_str(&format!("- {f}\n"));
594 }
595 s.push('\n');
596 }
597 out.push(("superseded_findings", s));
598
599 out
600}
601
602pub fn render_fidelity_report(
613 report: &FidelityReport,
614 budget: usize,
615 include: &[String],
616) -> RenderedFidelityReport {
617 let hard = render_hard_required(report);
618 let hard_cost = estimate_tokens(&hard);
619 let overbudget = hard_cost > budget;
620
621 let include_set: std::collections::BTreeSet<&str> = include
622 .iter()
623 .map(String::as_str)
624 .filter(|k| ALLOWED_REPORT_INCLUDE_KEYS.contains(k))
625 .collect();
626 let unknown_includes: Vec<&String> = include
627 .iter()
628 .filter(|k| !ALLOWED_REPORT_INCLUDE_KEYS.contains(&k.as_str()))
629 .collect();
630
631 let sections = heavy_sections(report);
632 let mut emitted: Vec<String> = Vec::new();
633 let mut hints: Vec<(String, usize)> = Vec::new();
634 let mut used = hard_cost;
635 let mut remaining = budget.saturating_sub(hard_cost);
636
637 for (key, section_md) in §ions {
638 if section_md.is_empty() {
639 continue; }
641 let cost = estimate_tokens(section_md);
642 let forced = include_set.contains(key);
643 if forced {
644 emitted.push(section_md.clone());
645 used += cost;
646 remaining = remaining.saturating_sub(cost);
647 } else if !overbudget && remaining >= cost {
648 emitted.push(section_md.clone());
649 used += cost;
650 remaining -= cost;
651 } else {
652 hints.push(((*key).to_string(), cost));
653 }
654 }
655
656 let mode = if overbudget {
657 "overbudget"
658 } else if hints.is_empty() {
659 "complete"
660 } else {
661 "reduced"
662 };
663
664 let mut md = String::new();
665 md.push_str("---\n");
666 md.push_str(&format!("_report_mode: {mode}\n"));
667 md.push_str(&format!("_budget_requested: {budget}\n"));
668 md.push_str(&format!("_budget_used: {used}\n"));
669 md.push_str("---\n\n");
670 md.push_str(&hard);
671 for section in &emitted {
672 md.push_str(section);
673 }
674
675 if !hints.is_empty() {
676 md.push_str("## Hints\n\n");
677 md.push_str(
678 "_(heavy sections omitted under the token budget — re-query with the key)_\n\n",
679 );
680 for (key, tokens) in &hints {
681 md.push_str(&format!("- `{key}` — estimated_tokens: {tokens}\n"));
682 }
683 md.push('\n');
684 }
685
686 if !unknown_includes.is_empty() {
687 md.push_str("## Warnings\n\n");
688 for k in &unknown_includes {
689 md.push_str(&format!(
690 "- unknown include key `{k}` — allowed: {}\n",
691 ALLOWED_REPORT_INCLUDE_KEYS.join(", ")
692 ));
693 }
694 md.push('\n');
695 }
696
697 RenderedFidelityReport {
698 markdown: md,
699 mode: mode.to_string(),
700 hints,
701 budget_used: used,
702 }
703}
704
705pub fn compute_fidelity_report(
719 engine: &Engine,
720 workspace_root: &Path,
721 binding: &BindingV1,
722 resolved: &ResolvedIngest,
723 key: &FindingKey,
724) -> FidelityReport {
725 let binding_id = resolved.name.clone();
726 let dest = resolved.destination_mem.clone();
727
728 let sync_state = engine
730 .mem_config_for(&dest)
731 .map(|c| c.sync_state.clone())
732 .unwrap_or_default();
733 let mut capabilities: Vec<FacetCapability> = Vec::new();
734 let mut freshness: Vec<FacetFreshness> = Vec::new();
735 let mut any_change_detectable = false;
736 for source in &resolved.sources {
737 let ResolvedSource::Primary(p) = source else {
738 continue;
739 };
740 let caps = medium_capabilities(p.medium_type);
741 let medium_type = serde_json::to_value(p.medium_type)
742 .ok()
743 .and_then(|v| v.as_str().map(str::to_string))
744 .unwrap_or_default();
745 let strategy = resolve_change_strategy(p, workspace_root);
746 let signal = signal_wire(strategy).to_string();
747 let change_detectable = caps.change_signal && strategy != ChangeStrategy::None;
748 any_change_detectable |= change_detectable;
749
750 capabilities.push(FacetCapability::from_caps(
751 p.facet_ref.clone(),
752 medium_type,
753 caps,
754 strategy,
755 ));
756
757 let synced = sync_state
758 .get(&format!("{binding_id}/{}#synced", p.facet_ref))
759 .cloned();
760 let verified = sync_state
761 .get(&format!("{binding_id}/{}#verified", p.facet_ref))
762 .cloned();
763 freshness.push(FacetFreshness {
764 facet: p.facet_ref.clone(),
765 signal,
766 synced,
767 verified,
768 change_detectable,
769 });
770 }
771
772 let source_moved_past_synced = if any_change_detectable {
773 Some(source_moved(engine, resolved, workspace_root))
774 } else {
775 None
776 };
777
778 let mut s_d: Vec<String> = Vec::new();
780 let mut enumerable_facets = 0usize;
781 for source in &resolved.sources {
782 if let ResolvedSource::Primary(p) = source {
783 let caps = medium_capabilities(p.medium_type);
784 if caps.enumerable {
785 enumerable_facets += 1;
786 }
787 s_d.extend(enumerate_facet_files(
788 p,
789 &resolved.deny_paths,
790 workspace_root,
791 ));
792 }
793 }
794 s_d.sort();
795 s_d.dedup();
796
797 let denominator = if !s_d.is_empty() {
798 DenominatorBasis::Enumerated { count: s_d.len() }
799 } else if enumerable_facets == 0 {
800 DenominatorBasis::NonEnumerable {
801 reason: "the medium type(s) are not enumerable this cycle".to_string(),
802 }
803 } else {
804 DenominatorBasis::NonEnumerable {
807 reason: "no source artifacts enumerated in scope".to_string(),
808 }
809 };
810
811 let mut direct_covered = 0usize;
812 let mut tree_only_covered = 0usize;
813 let mut uncovered: Vec<String> = Vec::new();
814 let mut tree_fanout: BTreeMap<(String, String), usize> = BTreeMap::new();
815 for file in &s_d {
816 let refs = engine.anchors_referencing_artifact(file);
817 let mine: Vec<&(crate::EntityId, crate::anchor::Anchor)> = refs
818 .iter()
819 .filter(|(eid, _)| eid.mem() == dest.as_str())
820 .collect();
821 if mine.is_empty() {
822 uncovered.push(file.clone());
823 continue;
824 }
825 let has_non_tree = mine.iter().any(|(_, a)| a.grain != AnchorGrain::Tree);
826 if has_non_tree {
827 direct_covered += 1;
828 } else {
829 tree_only_covered += 1;
830 }
831 for (eid, a) in &mine {
833 if a.grain == AnchorGrain::Tree {
834 *tree_fanout
835 .entry((eid.as_ref().to_string(), a.artifact.clone()))
836 .or_insert(0) += 1;
837 }
838 }
839 }
840 let tree_anchors: Vec<TreeFanout> = tree_fanout
841 .into_iter()
842 .map(|((entity, artifact), fanout)| TreeFanout {
843 entity,
844 artifact,
845 fanout,
846 })
847 .collect();
848
849 let coverage = GrainCoverage {
850 denominator,
851 direct_covered,
852 tree_only_covered,
853 uncovered: uncovered.clone(),
854 tree_anchors,
855 };
856
857 let mut anchors = AnchorComposition::default();
859 for (_eid, resolved_anchor) in engine.mem_anchors_resolved(&dest) {
860 let a = &resolved_anchor.anchor;
861 *anchors
862 .by_class
863 .entry(a.class.as_wire().to_string())
864 .or_insert(0) += 1;
865 *anchors
866 .by_grain
867 .entry(a.grain.as_wire().to_string())
868 .or_insert(0) += 1;
869 if a.class == AnchorProvenanceClass::Authored {
870 anchors.authored += 1;
871 continue; }
873 match resolved_anchor.state {
874 Some(AnchorState::Resolves) => {
875 anchors.resolves += 1;
876 anchors.observed += 1;
877 }
878 Some(AnchorState::Drifted) => {
879 anchors.drifted += 1;
880 anchors.observed += 1;
881 }
882 Some(AnchorState::Recheck) => {
883 anchors.recheck += 1;
884 anchors.observed += 1;
885 }
886 Some(AnchorState::Orphaned) => {
887 anchors.orphaned += 1;
888 anchors.observed += 1;
889 }
890 None => anchors.unobserved += 1,
891 }
892 }
893
894 let mut findings_by_class: BTreeMap<String, usize> = BTreeMap::new();
896 let mut backlog = 0usize;
897 let mut superseded: Vec<String> = Vec::new();
898 if let Some((mem, name)) = binding_id.split_once('/')
899 && let Ok(Some(store)) = read_findings_store(workspace_root, mem, name)
900 {
901 for f in store.current(key) {
902 *findings_by_class
903 .entry(f.class.as_wire().to_string())
904 .or_insert(0) += 1;
905 if f.class == FindingClass::QueuedForAdjudication {
906 backlog += 1;
907 }
908 }
909 for f in store.superseded(key) {
910 superseded.push(format!(
911 "[{}] {} ({})",
912 f.class.as_wire(),
913 finding_target_label(&f.target),
914 f.facet
915 ));
916 }
917 }
918
919 let mut disposed_excluded_rationales: Vec<(String, String)> = Vec::new();
925 if let Some((mem, name)) = binding_id.split_once('/')
926 && let Ok(Some(state)) = read_advance_store(workspace_root, mem, name)
927 {
928 let uncovered_set: std::collections::BTreeSet<&str> =
929 uncovered.iter().map(String::as_str).collect();
930 for (artifact, rationale) in &state.exclusions {
931 if uncovered_set.contains(artifact.as_str()) {
932 disposed_excluded_rationales.push((artifact.clone(), rationale.clone()));
933 }
934 }
935 }
936 let disposed_excluded = disposed_excluded_rationales.len();
937
938 let mut degradations: Vec<String> = Vec::new();
940 for c in &capabilities {
941 if !c.change_signal || c.signal == "none" {
942 degradations.push(format!(
943 "change-signal-none:`{}` — freshness is unknowable for this facet",
944 c.facet
945 ));
946 }
947 if !c.enumerable {
948 degradations.push(format!(
949 "enumeration-unavailable:`{}` — `S(D)` coverage denominator not computable",
950 c.facet
951 ));
952 }
953 if !c.base_version_retrievable {
954 degradations.push(format!(
955 "base-version-unretrievable:`{}` — prune degrades to conflict-flagging",
956 c.facet
957 ));
958 }
959 }
960 if anchors.recheck > 0 {
961 degradations.push(format!(
962 "hash-adjudication-deferred — {} anchor(s) recheck (unstable medium / hash \
963 unavailable), not asserted drift",
964 anchors.recheck
965 ));
966 }
967 if anchors.unobserved > 0 {
968 degradations.push(format!(
969 "anchors-unobserved — {} anchor(s) could not be observed this pass",
970 anchors.unobserved
971 ));
972 }
973
974 let adopt = super::render::mem_predates_binding(engine, resolved);
978
979 FidelityReport {
980 binding: binding_id,
981 destination_mem: dest,
982 adopt,
983 coverage_semantics: binding.coverage_semantics,
984 capabilities,
985 freshness,
986 source_moved_past_synced,
987 coverage,
988 anchors,
989 findings_by_class,
990 backlog,
991 superseded,
992 disposed_excluded,
993 disposed_excluded_rationales,
994 degradations,
995 }
996}
997
998fn strategy_retrieves_base(strategy: ChangeStrategy) -> bool {
1007 matches!(strategy, ChangeStrategy::Git | ChangeStrategy::Graph)
1008}
1009
1010fn signal_wire(strategy: ChangeStrategy) -> &'static str {
1013 match strategy {
1014 ChangeStrategy::None => "none",
1015 ChangeStrategy::Git => "git",
1016 ChangeStrategy::Mtime => "mtime",
1017 ChangeStrategy::Graph => "graph",
1018 }
1019}
1020
1021fn finding_target_label(target: &super::findings::FindingTarget) -> String {
1023 match target {
1024 super::findings::FindingTarget::Anchor { entity, artifact } => {
1025 format!("{entity} → {artifact}")
1026 }
1027 super::findings::FindingTarget::Artifact { artifact } => artifact.clone(),
1028 }
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033 use super::*;
1034
1035 fn base_report() -> FidelityReport {
1038 FidelityReport {
1039 binding: "engine/graph".to_string(),
1040 destination_mem: "engine".to_string(),
1041 adopt: false,
1042 coverage_semantics: CoverageSemantics::Exhaustive,
1043 capabilities: vec![FacetCapability {
1044 facet: "src".to_string(),
1045 medium_type: "codebase".to_string(),
1046 enumerable: true,
1047 change_signal: true,
1048 base_version_retrievable: true,
1049 anchor_namespace: "path".to_string(),
1050 signal: "git".to_string(),
1051 }],
1052 freshness: vec![FacetFreshness {
1053 facet: "src".to_string(),
1054 signal: "git".to_string(),
1055 synced: Some("deadbeef".to_string()),
1056 verified: None,
1057 change_detectable: true,
1058 }],
1059 source_moved_past_synced: Some(false),
1060 coverage: GrainCoverage {
1061 denominator: DenominatorBasis::Enumerated { count: 10 },
1062 direct_covered: 6,
1063 tree_only_covered: 3,
1064 uncovered: vec!["src/a.rs".to_string()],
1065 tree_anchors: vec![TreeFanout {
1066 entity: "engine--big".to_string(),
1067 artifact: "src/".to_string(),
1068 fanout: 3,
1069 }],
1070 },
1071 anchors: AnchorComposition {
1072 by_class: BTreeMap::from([
1073 ("anchored".to_string(), 5),
1074 ("authored".to_string(), 2),
1075 ]),
1076 by_grain: BTreeMap::from([("file".to_string(), 4), ("tree".to_string(), 1)]),
1077 authored: 2,
1078 observed: 5,
1079 resolves: 4,
1080 drifted: 0,
1081 recheck: 1,
1082 orphaned: 0,
1083 unobserved: 0,
1084 },
1085 findings_by_class: BTreeMap::from([
1086 ("uncovered".to_string(), 1),
1087 ("queued-for-adjudication".to_string(), 1),
1088 ]),
1089 backlog: 1,
1090 superseded: Vec::new(),
1091 disposed_excluded: 0,
1092 disposed_excluded_rationales: Vec::new(),
1093 degradations: vec!["hash-adjudication-deferred — 1 anchor(s) recheck".to_string()],
1094 }
1095 }
1096
1097 #[test]
1102 fn b1_renders_all_elements_deterministically() {
1103 let r = base_report();
1104 let a = render_fidelity_report(&r, 8_000, &[]);
1105 let b = render_fidelity_report(&r, 8_000, &[]);
1106 assert_eq!(a.markdown, b.markdown, "deterministic — identical bytes");
1107
1108 let md = &a.markdown;
1109 assert!(md.contains("direct-covered (file / span anchors): 6/10"));
1111 assert!(md.contains(
1112 "tree-anchor fan-out (separate axis): 1 tree anchor(s) fanning out over 3 file(s)"
1113 ));
1114 assert!(
1116 !md.contains("9/10"),
1117 "tree fan-out must not blend into direct coverage"
1118 );
1119 assert!(md.contains("anchor-resolution %:** 4/5"));
1121 assert!(md.contains("`authored` bucket (excluded from coverage/accuracy denominators): 2"));
1123 assert!(md.contains("tier-3 adjudication backlog:** 1"));
1125 assert!(md.contains("## Capability matrix"));
1127 assert!(md.contains("## Degradations"));
1128 assert!(md.contains("hash-adjudication-deferred"));
1129 assert!(md.contains("per-medium enumeration `S(D)` = **10**"));
1131 }
1132
1133 #[test]
1136 fn b2_detectionless_medium_freshness_unknowable_never_green() {
1137 let mut r = base_report();
1138 r.capabilities = vec![FacetCapability {
1139 facet: "manual".to_string(),
1140 medium_type: "web".to_string(),
1141 enumerable: false,
1142 change_signal: false,
1143 base_version_retrievable: false,
1144 anchor_namespace: "url".to_string(),
1145 signal: "none".to_string(),
1146 }];
1147 r.freshness = vec![FacetFreshness {
1148 facet: "manual".to_string(),
1149 signal: "none".to_string(),
1150 synced: Some("should-never-render-green".to_string()),
1153 verified: Some("nor-this".to_string()),
1154 change_detectable: false,
1155 }];
1156 r.source_moved_past_synced = None;
1157 let out = render_fidelity_report(&r, 8_000, &[]);
1158 let md = &out.markdown;
1159 assert!(md.contains("signal: `none`"));
1160 assert!(md.contains("freshness unknowable"));
1161 assert!(!md.contains("should-never-render-green"));
1164 assert!(
1165 !md.contains("`#synced`: `"),
1166 "no synced token rendered for a non-detectable medium"
1167 );
1168 assert!(
1169 !md.contains("at its `#synced` baseline"),
1170 "no green 'at baseline' verdict"
1171 );
1172 }
1173
1174 #[test]
1182 fn b1_base_retrievability_follows_resolved_strategy_not_medium_ceiling() {
1183 use crate::pipeline::MediumType;
1184
1185 assert!(medium_capabilities(MediumType::Filesystem).base_version_retrievable);
1187
1188 let fs_mtime = FacetCapability::from_caps(
1190 "prose".to_string(),
1191 "filesystem".to_string(),
1192 medium_capabilities(MediumType::Filesystem),
1193 ChangeStrategy::Mtime,
1194 );
1195 assert!(
1196 !fs_mtime.base_version_retrievable,
1197 "filesystem+mtime has no retrievable base leg — degrades to conflict-flag"
1198 );
1199 assert_eq!(fs_mtime.signal, "mtime");
1200
1201 let fs_git = FacetCapability::from_caps(
1202 "prose".to_string(),
1203 "filesystem".to_string(),
1204 medium_capabilities(MediumType::Filesystem),
1205 ChangeStrategy::Git,
1206 );
1207 assert!(
1208 fs_git.base_version_retrievable,
1209 "filesystem backed by git keeps the never-clobber base leg"
1210 );
1211
1212 assert!(!strategy_retrieves_base(ChangeStrategy::None));
1214 assert!(!strategy_retrieves_base(ChangeStrategy::Mtime));
1215 assert!(strategy_retrieves_base(ChangeStrategy::Git));
1216 assert!(strategy_retrieves_base(ChangeStrategy::Graph));
1217
1218 let mut r = base_report();
1222 r.capabilities = vec![fs_mtime.clone()];
1223 r.degradations = if !fs_mtime.base_version_retrievable {
1224 vec![format!(
1225 "base-version-unretrievable:`{}` — prune degrades to conflict-flagging",
1226 fs_mtime.facet
1227 )]
1228 } else {
1229 Vec::new()
1230 };
1231 let md = render_fidelity_report(&r, 8_000, &[]).markdown;
1232 assert!(
1233 md.contains("base-version-unretrievable:`prose` — prune degrades to conflict-flagging"),
1234 "filesystem+mtime surfaces the conflict-flag degradation in the report"
1235 );
1236 }
1237
1238 #[test]
1241 fn b3_aggregates_always_ship_at_zero_budget() {
1242 let r = base_report();
1243 let out = render_fidelity_report(&r, 0, &[]);
1244 assert_eq!(out.mode, "overbudget");
1245 let md = &out.markdown;
1246 assert!(md.contains("direct-covered (file / span anchors): 6/10"));
1248 assert!(md.contains("tier-3 adjudication backlog:** 1"));
1249 assert!(md.contains("## Capability matrix"));
1250 assert!(!md.contains("## Uncovered artifacts"));
1252 assert!(md.contains("## Hints"));
1253 assert!(out.hints.iter().any(|(k, _)| k == "uncovered_artifacts"));
1254 }
1255
1256 #[test]
1260 fn b3_large_facet_list_truncates_then_include_forces() {
1261 let mut r = base_report();
1262 r.coverage.uncovered = (0..500).map(|i| format!("src/file_{i}.rs")).collect();
1264 let hard_cost = estimate_tokens(&render_hard_required(&r));
1266 let out = render_fidelity_report(&r, hard_cost + 5, &[]);
1267 assert_eq!(out.mode, "reduced");
1268 assert!(
1269 !out.markdown.contains("src/file_499.rs"),
1270 "big list not rendered unbounded"
1271 );
1272 assert!(out.markdown.contains("## Hints"));
1273 let (_, est) = out
1274 .hints
1275 .iter()
1276 .find(|(k, _)| k == "uncovered_artifacts")
1277 .expect("uncovered list hinted");
1278 assert!(*est > 5, "the hint carries a real estimated_tokens figure");
1279
1280 let forced =
1282 render_fidelity_report(&r, hard_cost + 5, &["uncovered_artifacts".to_string()]);
1283 assert!(
1284 forced.markdown.contains("src/file_499.rs"),
1285 "include forces the full list"
1286 );
1287 }
1288
1289 #[test]
1292 fn b4_curated_vs_exhaustive_framing() {
1293 let mut exhaustive = base_report();
1294 exhaustive.coverage_semantics = CoverageSemantics::Exhaustive;
1295 let ex_md = render_fidelity_report(&exhaustive, 8_000, &[]).markdown;
1296 assert!(ex_md.contains("Exhaustive coverage:"));
1297 assert!(ex_md.contains("are **findings**"));
1298
1299 let mut curated = base_report();
1300 curated.coverage_semantics = CoverageSemantics::Curated;
1301 let cur_md = render_fidelity_report(&curated, 8_000, &[]).markdown;
1302 assert!(cur_md.contains("Curated coverage:"));
1303 assert!(cur_md.contains("**information**"));
1304 assert!(
1305 !cur_md.contains("are **findings**"),
1306 "curated never frames unaccounted as findings"
1307 );
1308 }
1309
1310 #[test]
1313 fn b4_disposition_excludes_from_exhaustive_findings() {
1314 let mut r = base_report();
1315 r.coverage_semantics = CoverageSemantics::Exhaustive;
1316 r.coverage.uncovered = vec!["src/a.rs".to_string(), "src/b.rs".to_string()];
1317 r.disposed_excluded = 1;
1318 let md = render_fidelity_report(&r, 8_000, &[]).markdown;
1319 assert!(md.contains("1 unaccounted artifact(s)"));
1321 assert!(md.contains("(1 disposed excluded)"));
1322 }
1323
1324 #[test]
1327 fn b4_authored_exclusion_rationale_is_rendered() {
1328 let mut r = base_report();
1329 r.coverage_semantics = CoverageSemantics::Exhaustive;
1330 r.coverage.uncovered = vec!["src/gen.rs".to_string()];
1331 r.disposed_excluded = 1;
1332 r.disposed_excluded_rationales =
1333 vec![("src/gen.rs".to_string(), "generated; no entity".to_string())];
1334 let md = render_fidelity_report(&r, 8_000, &[]).markdown;
1335 assert!(md.contains("Excluded on purpose (persisted dispositions):"));
1336 assert!(md.contains("`src/gen.rs` — generated; no entity"));
1337 }
1338
1339 #[test]
1342 fn b5_denominator_provenance_stated() {
1343 let r = base_report();
1344 let md = render_fidelity_report(&r, 8_000, &[]).markdown;
1345 assert!(md.contains("## Denominator provenance"));
1346 assert!(md.contains("per-medium enumeration `S(D)` = **10**"));
1347
1348 let mut non = base_report();
1349 non.coverage.denominator = DenominatorBasis::NonEnumerable {
1350 reason: "the medium type(s) are not enumerable this cycle".to_string(),
1351 };
1352 let md2 = render_fidelity_report(&non, 8_000, &[]).markdown;
1353 assert!(md2.contains("No `S(D)` denominator"));
1354 assert!(md2.contains("not enumerable this cycle"));
1355 }
1356
1357 #[test]
1363 fn e1_adopt_report_renders_onboarding_no_red_verdict() {
1364 let mut r = base_report();
1365 r.adopt = true;
1366 r.coverage_semantics = CoverageSemantics::Exhaustive;
1367 r.coverage.uncovered = (0..5).map(|i| format!("src/file_{i}.rs")).collect();
1368 let md = render_fidelity_report(&r, 8_000, &[]).markdown;
1369
1370 assert!(md.contains("## Adopting — first verify"));
1372 assert!(md.contains("0% anchored is expected — this is onboarding, not a failure."));
1373 assert!(md.contains("**Backfill path:** run `memstead projection sync engine/graph`"));
1375 assert!(
1378 !md.contains("are **findings**"),
1379 "pre-binding history must not produce a red findings verdict"
1380 );
1381 assert!(md.contains("Exhaustive coverage (onboarding):"));
1382 assert!(md.contains("backfill worklist"));
1383
1384 r.adopt = false;
1386 let md2 = render_fidelity_report(&r, 8_000, &[]).markdown;
1387 assert!(!md2.contains("## Adopting — first verify"));
1388 assert!(md2.contains("are **findings**"));
1389 }
1390
1391 #[test]
1393 fn unknown_include_key_warns() {
1394 let r = base_report();
1395 let out = render_fidelity_report(&r, 8_000, &["bogus".to_string()]);
1396 assert!(out.markdown.contains("unknown include key `bogus`"));
1397 }
1398
1399 use crate::anchor::{Anchor, AnchorHashStability, AnchorProvenanceClass, AnchorSidecar};
1402 use crate::binding::{
1403 BINDING_VERSION, BindingV1, BuildMode, BuildOperation, DEFAULT_ADJUDICATION_CAP,
1404 DEFAULT_FULL_RESYNC_EVERY, Operations, VerifyOperation,
1405 };
1406 use crate::ingest::findings::verify_binding;
1407 use crate::ingest::resolve::resolve_binding_run;
1408 use crate::pipeline::{Facet, IngestTrigger, Medium, MediumType, PatternEntry, PatternMode};
1409 use crate::pipeline_store::{load_pipeline_configs, write_binding, write_facet, write_medium};
1410 use crate::workspace::{
1411 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
1412 };
1413 use crate::workspace_store::WorkspaceStoreAdapter;
1414
1415 #[test]
1422 fn compute_report_end_to_end() {
1423 let tmp = tempfile::tempdir().unwrap();
1424 let root = tmp.path();
1425 let mem_dir = root.join("mem");
1426 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1427 std::fs::write(
1428 mem_dir.join(".memstead").join("config.json"),
1429 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1430 )
1431 .unwrap();
1432
1433 std::fs::create_dir_all(root.join(".memstead")).unwrap();
1434 std::fs::write(
1435 root.join(".memstead").join("workspace.toml"),
1436 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1437 )
1438 .unwrap();
1439 let mount = Mount {
1440 mem: "engine".to_string(),
1441 schema: Some("default@1.0.0".parse().unwrap()),
1442 storage: MountStorage::Folder {
1443 path: mem_dir.clone(),
1444 },
1445 capability: MountCapability::Write,
1446 lifecycle: MountLifecycle::Eager,
1447 cross_linkable: false,
1448 migration_target: None,
1449 };
1450 crate::FileWorkspaceStore::new()
1451 .save_state(
1452 root,
1453 &Workspace {
1454 mounts: vec![mount],
1455 settings: WorkspaceSettings::default(),
1456 },
1457 )
1458 .unwrap();
1459
1460 let out = std::process::Command::new("git")
1461 .args(["init", "-q"])
1462 .current_dir(root)
1463 .output()
1464 .unwrap();
1465 assert!(out.status.success());
1466 std::fs::create_dir_all(root.join("src").join("sub")).unwrap();
1467 std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
1468 std::fs::write(root.join("src").join("uncovered.rs"), "fn b() {}\n").unwrap();
1469 std::fs::write(root.join("src").join("sub").join("deep.rs"), "fn c() {}\n").unwrap();
1470
1471 let mk = |artifact: &str, grain: AnchorGrain, class: AnchorProvenanceClass| Anchor {
1472 artifact: artifact.to_string(),
1473 grain,
1474 class,
1475 at_version: None,
1476 hash: class.is_hash_bearing().then(|| "recorded".to_string()),
1477 hash_stability: AnchorHashStability::Stable,
1478 derived_from: Vec::new(),
1479 binding: None,
1480 };
1481 let mut sidecar = AnchorSidecar::default();
1482 sidecar.set(
1483 "engine--direct",
1484 vec![mk(
1485 "src/present.rs",
1486 AnchorGrain::File,
1487 AnchorProvenanceClass::Anchored,
1488 )],
1489 );
1490 sidecar.set(
1491 "engine--tree",
1492 vec![mk(
1493 "src/sub/",
1494 AnchorGrain::Tree,
1495 AnchorProvenanceClass::Anchored,
1496 )],
1497 );
1498 sidecar.set(
1500 "engine--auth",
1501 vec![mk(
1502 "src/present.rs",
1503 AnchorGrain::File,
1504 AnchorProvenanceClass::Authored,
1505 )],
1506 );
1507 std::fs::write(
1508 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
1509 sidecar.to_bytes(),
1510 )
1511 .unwrap();
1512
1513 write_medium(
1514 root,
1515 "engine",
1516 "graph",
1517 &Medium {
1518 name: "graph".to_string(),
1519 medium_type: MediumType::Codebase,
1520 pointer: String::new(),
1521 change_detection: Some("git".to_string()),
1522 },
1523 )
1524 .unwrap();
1525 write_facet(
1526 root,
1527 "engine",
1528 "graph",
1529 &Facet {
1530 name: "graph".to_string(),
1531 medium: "graph".to_string(),
1532 scope: vec![PatternEntry {
1533 path: "src/**/*.rs".to_string(),
1534 mode: PatternMode::Allow,
1535 }],
1536 engagement: None,
1537 preparation: None,
1538 },
1539 )
1540 .unwrap();
1541 write_binding(
1542 root,
1543 "engine",
1544 "graph",
1545 &BindingV1 {
1546 version: BINDING_VERSION,
1547 intent: None,
1548 source_facets: vec!["graph".to_string()],
1549 reference_mems: Vec::new(),
1550 destination_mem: "engine".to_string(),
1551 deny_paths: Vec::new(),
1552 coverage_semantics: CoverageSemantics::Exhaustive,
1553 rules: None,
1554 prune: None,
1555 operations: Operations {
1556 build: Some(BuildOperation {
1557 mode: BuildMode::Discovery,
1558 trigger: IngestTrigger::Loop,
1559 batch_size: 20,
1560 post_actions: None,
1561 }),
1562 sync: None,
1563 verify: Some(VerifyOperation {
1564 trigger: IngestTrigger::Manual,
1565 batch_size: 20,
1566 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
1567 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
1568 }),
1569 },
1570 },
1571 )
1572 .unwrap();
1573
1574 let engine = Engine::from_workspace_root(root).unwrap();
1575 let configs = load_pipeline_configs(root).unwrap();
1576 let binding = &configs.bindings[0].config;
1577 let resolved = resolve_binding_run(&configs, "engine/graph", binding).unwrap();
1578
1579 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
1581
1582 let report = compute_fidelity_report(&engine, root, binding, &resolved, &outcome.key);
1584
1585 assert_eq!(
1587 report.coverage.denominator,
1588 DenominatorBasis::Enumerated { count: 3 }
1589 );
1590 assert_eq!(report.coverage.direct_covered, 1);
1593 assert_eq!(report.coverage.tree_only_covered, 1);
1594 assert_eq!(
1595 report.coverage.uncovered,
1596 vec!["src/uncovered.rs".to_string()]
1597 );
1598 assert_eq!(report.coverage.tree_anchors.len(), 1);
1600 assert_eq!(report.coverage.tree_anchors[0].fanout, 1);
1601 assert_eq!(report.coverage.tree_anchors[0].artifact, "src/sub/");
1602 assert_eq!(report.anchors.authored, 1);
1604 assert_eq!(report.anchors.by_class.get("authored"), Some(&1));
1605 assert_eq!(report.anchors.observed, 2);
1608 assert_eq!(report.anchors.recheck, 2);
1609 assert_eq!(report.anchors.drifted, 0);
1610 assert_eq!(report.backlog, outcome.backlog);
1612 assert!(
1614 report
1615 .degradations
1616 .iter()
1617 .any(|d| d.contains("hash-adjudication-deferred"))
1618 );
1619 let md = render_fidelity_report(&report, 8_000, &[]).markdown;
1621 assert!(md.contains("per-medium enumeration `S(D)` = **3**"));
1622 assert!(!report.adopt);
1625 assert!(!md.contains("## Adopting — first verify"));
1626 }
1627
1628 #[test]
1633 fn compute_report_adopt_when_mem_predates_binding() {
1634 let tmp = tempfile::tempdir().unwrap();
1635 let root = tmp.path();
1636 let mem_dir = root.join("mem");
1637 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1638 std::fs::write(
1639 mem_dir.join(".memstead").join("config.json"),
1640 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1641 )
1642 .unwrap();
1643 std::fs::create_dir_all(root.join(".memstead")).unwrap();
1644 std::fs::write(
1645 root.join(".memstead").join("workspace.toml"),
1646 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1647 )
1648 .unwrap();
1649 let mount = Mount {
1650 mem: "engine".to_string(),
1651 schema: Some("default@1.0.0".parse().unwrap()),
1652 storage: MountStorage::Folder {
1653 path: mem_dir.clone(),
1654 },
1655 capability: MountCapability::Write,
1656 lifecycle: MountLifecycle::Eager,
1657 cross_linkable: false,
1658 migration_target: None,
1659 };
1660 crate::FileWorkspaceStore::new()
1661 .save_state(
1662 root,
1663 &Workspace {
1664 mounts: vec![mount],
1665 settings: WorkspaceSettings::default(),
1666 },
1667 )
1668 .unwrap();
1669 let out = std::process::Command::new("git")
1670 .args(["init", "-q"])
1671 .current_dir(root)
1672 .output()
1673 .unwrap();
1674 assert!(out.status.success());
1675 std::fs::create_dir_all(root.join("src")).unwrap();
1676 std::fs::write(root.join("src").join("a.rs"), "fn a() {}\n").unwrap();
1678 std::fs::write(root.join("src").join("b.rs"), "fn b() {}\n").unwrap();
1679
1680 write_medium(
1681 root,
1682 "engine",
1683 "graph",
1684 &Medium {
1685 name: "graph".to_string(),
1686 medium_type: MediumType::Codebase,
1687 pointer: String::new(),
1688 change_detection: Some("git".to_string()),
1689 },
1690 )
1691 .unwrap();
1692 write_facet(
1693 root,
1694 "engine",
1695 "graph",
1696 &Facet {
1697 name: "graph".to_string(),
1698 medium: "graph".to_string(),
1699 scope: vec![PatternEntry {
1700 path: "src/**/*.rs".to_string(),
1701 mode: PatternMode::Allow,
1702 }],
1703 engagement: None,
1704 preparation: None,
1705 },
1706 )
1707 .unwrap();
1708 write_binding(
1709 root,
1710 "engine",
1711 "graph",
1712 &BindingV1 {
1713 version: BINDING_VERSION,
1714 intent: None,
1715 source_facets: vec!["graph".to_string()],
1716 reference_mems: Vec::new(),
1717 destination_mem: "engine".to_string(),
1718 deny_paths: Vec::new(),
1719 coverage_semantics: CoverageSemantics::Exhaustive,
1720 rules: None,
1721 prune: None,
1722 operations: Operations {
1723 build: Some(BuildOperation {
1724 mode: BuildMode::Discovery,
1725 trigger: IngestTrigger::Loop,
1726 batch_size: 20,
1727 post_actions: None,
1728 }),
1729 sync: None,
1730 verify: Some(VerifyOperation {
1731 trigger: IngestTrigger::Manual,
1732 batch_size: 20,
1733 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
1734 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
1735 }),
1736 },
1737 },
1738 )
1739 .unwrap();
1740
1741 let engine = Engine::from_workspace_root(root).unwrap();
1742 let configs = load_pipeline_configs(root).unwrap();
1743 let binding = &configs.bindings[0].config;
1744 let resolved = resolve_binding_run(&configs, "engine/graph", binding).unwrap();
1745 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
1746 let report = compute_fidelity_report(&engine, root, binding, &resolved, &outcome.key);
1747
1748 assert!(
1750 report.adopt,
1751 "a no-anchor, never-synced mem predates its binding"
1752 );
1753 let md = render_fidelity_report(&report, 8_000, &[]).markdown;
1754 assert!(md.contains("## Adopting — first verify"));
1755 assert!(md.contains("0% anchored is expected"));
1756 assert!(!md.contains("are **findings**"));
1758 assert!(md.contains("Exhaustive coverage (onboarding):"));
1759 }
1760}