Skip to main content

okf_studio/
snapshot.rs

1//! The immutable world model: one internally consistent view of the bundle,
2//! rebuilt in whole on the worker thread.
3//!
4//! Everything the UI shows comes from here — no widget ever calls
5//! [`Bundle::load`] or touches the disk. Parse errors are not an error state:
6//! broken files appear in the tree with a marker, matching okf-core's
7//! permissive-loading design.
8
9use crate::graph::GraphModel;
10use crate::search::{SearchEntry, SearchIndex};
11use okf_core::log::{Log, LogEntry};
12use okf_core::{
13    ActorKind, AttestedComputation, Bundle, BundleError, ComputationSource, ConceptId, Date,
14    DateTimeField, Status, TrustTier,
15};
16use okf_validator::{Report, check_syntax, lint_bundle_at, validate_bundle_at};
17use std::collections::{BTreeMap, HashMap};
18use std::path::PathBuf;
19
20/// Precomputed per-concept facts, so rendering is pure lookup.
21#[derive(Clone, Debug)]
22pub struct ConceptMeta {
23    /// Trust tier.
24    pub tier: TrustTier,
25    /// Lifecycle status.
26    pub status: Status,
27    /// Whether the concept is stale on the snapshot's `today`.
28    pub stale: bool,
29    /// Days until `stale_after` when it lies within the next 30 days.
30    pub stale_in_days: Option<i64>,
31    /// Days since `stale_after` passed, when stale.
32    pub overdue_days: Option<i64>,
33    /// The raw `stale_after` field.
34    pub stale_after: Option<DateTimeField>,
35    /// Validation errors attributed to this concept.
36    pub diag_errors: usize,
37    /// Validation warnings attributed to this concept.
38    pub diag_warnings: usize,
39    /// Lint findings attributed to this concept.
40    pub lint_findings: usize,
41    /// Outgoing link count.
42    pub out_degree: usize,
43    /// Incoming link count.
44    pub in_degree: usize,
45    /// Outgoing links whose target does not exist.
46    pub broken_out: usize,
47    /// Number of `sources` entries.
48    pub source_count: usize,
49    /// Whether the concept is an Attested Computation.
50    pub is_computation: bool,
51    /// Body headings, as `(level, text)`.
52    pub headings: Vec<(usize, String)>,
53}
54
55/// Aggregated activity of one actor across the bundle.
56#[derive(Clone, Debug, Default)]
57pub struct ActorStats {
58    /// The actor's kind.
59    pub kind: Option<ActorKind>,
60    /// How many concepts this actor `generated`.
61    pub generated: usize,
62    /// How many verification events this actor signed.
63    pub verified: usize,
64}
65
66/// The `okf info` numbers, precomputed for the status bar and dashboards.
67#[derive(Clone, Debug, Default)]
68pub struct BundleStats {
69    /// Total concepts.
70    pub concepts: usize,
71    /// Files that failed to parse.
72    pub parse_errors: usize,
73    /// Concept counts by trust tier: `[unverified, machine, human]`.
74    pub tier_counts: [usize; 3],
75    /// Concept counts by status: `[draft, stable, deprecated, other]`.
76    pub status_counts: [usize; 4],
77    /// Concepts stale today.
78    pub stale: usize,
79    /// Concepts going stale within 30 days.
80    pub stale_soon: usize,
81    /// Broken outgoing links across the bundle.
82    pub broken_links: usize,
83    /// Concept counts by `type`.
84    pub types: BTreeMap<String, usize>,
85    /// Actor activity, aggregated from `generated.by` and `verified[].by`.
86    pub actors: BTreeMap<String, ActorStats>,
87}
88
89/// One row of the mission-control attention queue.
90#[derive(Clone, Debug)]
91pub struct AttentionItem {
92    /// The concept needing a decision.
93    pub id: ConceptId,
94    /// The transparent risk score the queue ranks by.
95    pub risk: i64,
96    /// Why the row ranks where it does.
97    pub reasons: Vec<String>,
98}
99
100/// A precomputed Attested Computation contract with its health checks.
101#[derive(Clone, Debug)]
102pub struct ContractInfo {
103    /// The computation concept's id.
104    pub id: ConceptId,
105    /// Display title.
106    pub title: String,
107    /// The typed contract.
108    pub contract: AttestedComputation,
109    /// Path-valued fields with their resolution result.
110    pub path_checks: Vec<(String, String, Option<PathBuf>)>,
111    /// Inline-code syntax verdict, when the computation is inline and the
112    /// runtime names a checkable language.
113    pub syntax: Option<Result<(), String>>,
114    /// Itemized failures; empty means the ✓ aggregate.
115    pub issues: Vec<String>,
116}
117
118impl ContractInfo {
119    /// `true` when every health check passes.
120    #[must_use]
121    pub const fn healthy(&self) -> bool {
122        self.issues.is_empty()
123    }
124}
125
126/// A leaf in the file tree.
127#[derive(Clone, Debug, PartialEq, Eq)]
128pub enum LeafKind {
129    /// A parsed concept.
130    Concept(ConceptId),
131    /// A reserved `index.md`.
132    Index(PathBuf),
133    /// A reserved `log.md`.
134    Log(PathBuf),
135    /// A file whose frontmatter failed to parse.
136    Broken(PathBuf),
137}
138
139/// One node of the file tree.
140#[derive(Clone, Debug)]
141pub enum TreeNode {
142    /// A directory with children.
143    Dir {
144        /// The directory's own name.
145        name: String,
146        /// The `/`-joined path from the bundle root, the collapse key.
147        path: String,
148        /// Recursive concept count.
149        concept_count: usize,
150        /// Child nodes, reserved files first, then dirs, then leaves.
151        children: Vec<Self>,
152    },
153    /// A file.
154    Leaf {
155        /// Display name (filename).
156        name: String,
157        /// What the file is.
158        kind: LeafKind,
159    },
160}
161
162/// The bundle's directory tree.
163#[derive(Clone, Debug, Default)]
164pub struct FileTree {
165    /// Top-level nodes.
166    pub roots: Vec<TreeNode>,
167}
168
169/// One immutable, internally consistent view of the world.
170#[derive(Debug)]
171pub struct Snapshot {
172    /// Monotonically increasing rebuild counter.
173    pub generation: u64,
174    /// The pinned or wall-clock date staleness is evaluated against.
175    pub today: Date,
176    /// The loaded bundle, as-is.
177    pub bundle: Bundle,
178    /// Conformance findings.
179    pub validation: Report,
180    /// Lint findings.
181    pub lint: Report,
182    /// The directory tree.
183    pub tree: FileTree,
184    /// Per-concept precomputed facts.
185    pub concept_meta: HashMap<ConceptId, ConceptMeta>,
186    /// The link graph model.
187    pub graph: GraphModel,
188    /// The omnisearch index.
189    pub search: SearchIndex,
190    /// Merged log activity: entry count per parseable date, ascending.
191    pub log_timeline: Vec<(Date, usize)>,
192    /// Merged log entries, newest date first, for the full log view.
193    pub log_days: Vec<(String, Vec<LogEntry>)>,
194    /// The `okf info` numbers.
195    pub stats: BundleStats,
196    /// The mission-control attention queue, highest risk first.
197    pub attention: Vec<AttentionItem>,
198    /// Attested Computation contracts with health checks.
199    pub contracts: Vec<ContractInfo>,
200}
201
202impl Snapshot {
203    /// Loads and derives a full snapshot.
204    ///
205    /// # Errors
206    ///
207    /// Returns [`BundleError`] only for I/O failures or a non-directory root;
208    /// per-file parse failures land in the tree and diagnostics instead.
209    pub fn build(
210        root: &std::path::Path,
211        today: Option<Date>,
212        generation: u64,
213    ) -> Result<Self, BundleError> {
214        let bundle = Bundle::load(root)?;
215        let today = today.or_else(Date::today_utc).unwrap_or(Date {
216            year: 2026,
217            month: 1,
218            day: 1,
219        });
220        let validation = validate_bundle_at(&bundle, Some(today));
221        let lint = lint_bundle_at(&bundle, Some(today));
222
223        let concept_meta = build_meta(&bundle, &validation, &lint, today);
224        let tree = build_tree(&bundle);
225        let graph = GraphModel::build(&bundle);
226        let search = build_search(&bundle, &concept_meta);
227        let (log_timeline, log_days) = build_log_views(&bundle);
228        let stats = build_stats(&bundle, &concept_meta);
229        let attention = build_attention(&bundle, &concept_meta);
230        let contracts = build_contracts(&bundle);
231
232        Ok(Self {
233            generation,
234            today,
235            bundle,
236            validation,
237            lint,
238            tree,
239            concept_meta,
240            graph,
241            search,
242            log_timeline,
243            log_days,
244            stats,
245            attention,
246            contracts,
247        })
248    }
249
250    /// The meta record for a concept.
251    #[must_use]
252    pub fn meta(&self, id: &ConceptId) -> Option<&ConceptMeta> {
253        self.concept_meta.get(id)
254    }
255}
256
257fn days_between(from: Date, to: Date) -> i64 {
258    to.days_since_epoch() - from.days_since_epoch()
259}
260
261fn build_meta(
262    bundle: &Bundle,
263    validation: &Report,
264    lint: &Report,
265    today: Date,
266) -> HashMap<ConceptId, ConceptMeta> {
267    let mut meta = HashMap::new();
268    for concept in bundle.concepts() {
269        let fm = &concept.document.frontmatter;
270        let stale_after = fm.stale_after();
271        let stale = concept.is_stale_on(today);
272        let effective = stale_after.as_ref().and_then(|f| {
273            f.datetime
274                .map(|dt| dt.utc_date())
275                .or_else(|| Date::parse(f.raw.trim().get(..10).unwrap_or("")))
276        });
277        let (stale_in_days, overdue_days) = effective.map_or((None, None), |date| {
278            let delta = days_between(today, date);
279            if stale {
280                (None, Some(-delta))
281            } else if (0..=30).contains(&delta) {
282                (Some(delta), None)
283            } else {
284                (None, None)
285            }
286        });
287        let links = bundle.links_from(&concept.id);
288        let count_for = |report: &Report, severity: Option<okf_validator::Severity>| {
289            report
290                .diagnostics
291                .iter()
292                .filter(|d| {
293                    d.concept.as_ref() == Some(&concept.id)
294                        || d.path.as_ref() == Some(&concept.path)
295                })
296                .filter(|d| severity.is_none_or(|s| d.severity == s))
297                .count()
298        };
299        meta.insert(
300            concept.id.clone(),
301            ConceptMeta {
302                tier: concept.trust_tier(),
303                status: concept.status(),
304                stale,
305                stale_in_days,
306                overdue_days,
307                stale_after,
308                diag_errors: count_for(validation, Some(okf_validator::Severity::Error)),
309                diag_warnings: count_for(validation, Some(okf_validator::Severity::Warning)),
310                lint_findings: count_for(lint, None),
311                out_degree: links.len(),
312                in_degree: bundle.backlinks(&concept.id).len(),
313                broken_out: links.iter().filter(|l| !l.exists).count(),
314                source_count: concept.sources().len(),
315                is_computation: concept.document.frontmatter.is_attested_computation(),
316                headings: okf_core::extract_headings(&concept.document.body)
317                    .iter()
318                    .map(|h| (h.level, h.text.to_string()))
319                    .collect(),
320            },
321        );
322    }
323    meta
324}
325
326#[allow(clippy::too_many_lines)]
327fn build_tree(bundle: &Bundle) -> FileTree {
328    /// Intermediate mutable directory.
329    #[derive(Default)]
330    struct DirBuilder {
331        dirs: BTreeMap<String, Self>,
332        leaves: Vec<(String, LeafKind)>,
333        concepts: usize,
334    }
335    fn insert(root: &mut DirBuilder, segments: &[String], leaf: (String, LeafKind)) {
336        let is_concept = matches!(leaf.1, LeafKind::Concept(_));
337        let mut cur = root;
338        if is_concept {
339            cur.concepts += 1;
340        }
341        for seg in segments {
342            cur = cur.dirs.entry(seg.clone()).or_default();
343            if is_concept {
344                cur.concepts += 1;
345            }
346        }
347        cur.leaves.push(leaf);
348    }
349    fn finish(builder: DirBuilder, prefix: &str) -> Vec<TreeNode> {
350        let mut reserved: Vec<TreeNode> = Vec::new();
351        let mut leaves: Vec<TreeNode> = Vec::new();
352        let mut sorted = builder.leaves;
353        sorted.sort_by(|a, b| a.0.cmp(&b.0));
354        for (name, kind) in sorted {
355            let node = TreeNode::Leaf { name, kind };
356            match &node {
357                TreeNode::Leaf {
358                    kind: LeafKind::Index(_) | LeafKind::Log(_),
359                    ..
360                } => reserved.push(node),
361                _ => leaves.push(node),
362            }
363        }
364        reserved.sort_by_key(|n| match n {
365            TreeNode::Leaf {
366                kind: LeafKind::Index(_),
367                ..
368            } => 0,
369            _ => 1,
370        });
371        let mut dirs: Vec<TreeNode> = Vec::new();
372        for (name, child) in builder.dirs {
373            let path = if prefix.is_empty() {
374                name.clone()
375            } else {
376                format!("{prefix}/{name}")
377            };
378            let concept_count = child.concepts;
379            let children = finish(child, &path);
380            dirs.push(TreeNode::Dir {
381                name,
382                path,
383                concept_count,
384                children,
385            });
386        }
387        let mut out = reserved;
388        out.extend(dirs);
389        out.extend(leaves);
390        out
391    }
392
393    let mut root = DirBuilder::default();
394    let rel_segments = |path: &std::path::Path| -> Option<(Vec<String>, String)> {
395        let rel = path.strip_prefix(bundle.root()).ok()?;
396        let mut segs: Vec<String> = rel
397            .components()
398            .filter_map(|c| match c {
399                std::path::Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
400                _ => None,
401            })
402            .collect();
403        let name = segs.pop()?;
404        Some((segs, name))
405    };
406    for concept in bundle.concepts() {
407        let segments = concept.id.segments();
408        let dirs = segments[..segments.len() - 1].to_vec();
409        insert(
410            &mut root,
411            &dirs,
412            (
413                format!("{}.md", concept.id.name()),
414                LeafKind::Concept(concept.id.clone()),
415            ),
416        );
417    }
418    for path in bundle.index_files() {
419        if let Some((dirs, name)) = rel_segments(path) {
420            insert(&mut root, &dirs, (name, LeafKind::Index(path.clone())));
421        }
422    }
423    for path in bundle.log_files() {
424        if let Some((dirs, name)) = rel_segments(path) {
425            insert(&mut root, &dirs, (name, LeafKind::Log(path.clone())));
426        }
427    }
428    for (path, _) in bundle.parse_errors() {
429        if let Some((dirs, name)) = rel_segments(path) {
430            insert(&mut root, &dirs, (name, LeafKind::Broken(path.clone())));
431        }
432    }
433    FileTree {
434        roots: finish(root, ""),
435    }
436}
437
438fn build_search(bundle: &Bundle, meta: &HashMap<ConceptId, ConceptMeta>) -> SearchIndex {
439    let entries = bundle
440        .concepts()
441        .iter()
442        .map(|concept| {
443            let m = meta.get(&concept.id);
444            SearchEntry {
445                id: concept.id.clone(),
446                title: concept.display_title(),
447                description: concept
448                    .document
449                    .frontmatter
450                    .description()
451                    .map(std::borrow::Cow::into_owned)
452                    .unwrap_or_default(),
453                tags: concept.document.frontmatter.tags(),
454                headings: m
455                    .map(|m| m.headings.iter().map(|(_, t)| t.clone()).collect())
456                    .unwrap_or_default(),
457                type_: concept
458                    .type_()
459                    .map(std::borrow::Cow::into_owned)
460                    .unwrap_or_default(),
461                tier: concept.trust_tier(),
462                status: concept.status(),
463                stale: m.is_some_and(|m| m.stale),
464                broken: m.is_some_and(|m| m.broken_out > 0),
465            }
466        })
467        .collect();
468    SearchIndex { entries }
469}
470
471/// The two log-derived views: the per-date activity counts and the merged
472/// newest-first day groups.
473type LogViews = (Vec<(Date, usize)>, Vec<(String, Vec<LogEntry>)>);
474
475fn build_log_views(bundle: &Bundle) -> LogViews {
476    let mut counts: BTreeMap<Date, usize> = BTreeMap::new();
477    let mut days: BTreeMap<String, Vec<LogEntry>> = BTreeMap::new();
478    for path in bundle.log_files() {
479        let Ok(text) = std::fs::read_to_string(path) else {
480            continue;
481        };
482        let log = Log::parse(&text);
483        for day in log.days {
484            if let Some(date) = Date::parse(day.date.trim()) {
485                *counts.entry(date).or_default() += day.entries.len();
486            }
487            days.entry(day.date.clone())
488                .or_default()
489                .extend(day.entries);
490        }
491    }
492    let timeline = counts.into_iter().collect();
493    // Newest first; non-date headings sort after real dates.
494    let mut merged: Vec<(String, Vec<LogEntry>)> = days.into_iter().collect();
495    merged.sort_by(|a, b| b.0.cmp(&a.0));
496    (timeline, merged)
497}
498
499fn build_stats(bundle: &Bundle, meta: &HashMap<ConceptId, ConceptMeta>) -> BundleStats {
500    let mut stats = BundleStats {
501        concepts: bundle.len(),
502        parse_errors: bundle.parse_errors().len(),
503        broken_links: bundle.broken_links().len(),
504        ..BundleStats::default()
505    };
506    for concept in bundle.concepts() {
507        let m = &meta[&concept.id];
508        stats.tier_counts[match m.tier {
509            TrustTier::Unverified => 0,
510            TrustTier::MachineConfirmed => 1,
511            TrustTier::HumanReviewed => 2,
512        }] += 1;
513        stats.status_counts[match m.status {
514            Status::Draft => 0,
515            Status::Stable => 1,
516            Status::Deprecated => 2,
517            Status::Other(_) => 3,
518        }] += 1;
519        if m.stale {
520            stats.stale += 1;
521        } else if m.stale_in_days.is_some() {
522            stats.stale_soon += 1;
523        }
524        let type_ = concept
525            .type_()
526            .map_or_else(|| "(untyped)".to_string(), std::borrow::Cow::into_owned);
527        *stats.types.entry(type_).or_default() += 1;
528
529        let fm = &concept.document.frontmatter;
530        if let Some(by) = fm.generated().and_then(|g| g.by) {
531            let entry = stats.actors.entry(by.as_str().to_string()).or_default();
532            entry.kind = Some(by.kind());
533            entry.generated += 1;
534        }
535        for verification in fm.verified() {
536            if let Some(by) = verification.by {
537                let entry = stats.actors.entry(by.as_str().to_string()).or_default();
538                entry.kind = Some(by.kind());
539                entry.verified += 1;
540            }
541        }
542    }
543    stats
544}
545
546fn build_attention(bundle: &Bundle, meta: &HashMap<ConceptId, ConceptMeta>) -> Vec<AttentionItem> {
547    let mut items = Vec::new();
548    for concept in bundle.concepts() {
549        let m = &meta[&concept.id];
550        let mut risk: i64 = 0;
551        let mut reasons: Vec<String> = Vec::new();
552        if let Some(overdue) = m.overdue_days {
553            risk += 40 + overdue.clamp(0, 60);
554            reasons.push(format!("stale {overdue}d"));
555        } else if let Some(days) = m.stale_in_days {
556            risk += 10 + (30 - days).max(0);
557            reasons.push(format!("stale in {days}d"));
558        } else if m.stale {
559            risk += 40;
560            reasons.push("stale".to_string());
561        }
562        match m.tier {
563            TrustTier::Unverified => {
564                risk += 25;
565                reasons.push("unverified".to_string());
566            }
567            TrustTier::MachineConfirmed => {
568                risk += 10;
569                reasons.push("machine-confirmed".to_string());
570            }
571            TrustTier::HumanReviewed => reasons.push("human-reviewed".to_string()),
572        }
573        if m.status.is_deprecated() && m.in_degree > 0 {
574            risk += 20;
575            reasons.push(format!(
576                "deprecated · {} incoming links remain",
577                m.in_degree
578            ));
579        }
580        if m.in_degree > 0 {
581            risk += i64::try_from(m.in_degree).unwrap_or(0).min(10) * 3;
582            reasons.push(format!("{} backlinks", m.in_degree));
583        }
584        let diags = m.diag_errors * 15 + m.diag_warnings * 5 + m.lint_findings * 2;
585        if diags > 0 {
586            risk += i64::try_from(diags).unwrap_or(0);
587            reasons.push(format!(
588                "{} diagnostic(s)",
589                m.diag_errors + m.diag_warnings + m.lint_findings
590            ));
591        }
592        let needs_attention = m.stale
593            || m.stale_in_days.is_some()
594            || m.tier == TrustTier::Unverified
595            || (m.status.is_deprecated() && m.in_degree > 0)
596            || m.diag_errors + m.diag_warnings > 0;
597        if needs_attention {
598            items.push(AttentionItem {
599                id: concept.id.clone(),
600                risk,
601                reasons,
602            });
603        }
604    }
605    items.sort_by(|a, b| b.risk.cmp(&a.risk).then_with(|| a.id.cmp(&b.id)));
606    items
607}
608
609fn build_contracts(bundle: &Bundle) -> Vec<ContractInfo> {
610    bundle
611        .attested_computations()
612        .filter_map(|concept| {
613            let contract = concept.attested_computation()?;
614            let mut issues = Vec::new();
615            if contract.runtime.is_none() {
616                issues.push("missing required `runtime`".to_string());
617            }
618            if contract.computation.is_missing() {
619                issues.push(
620                    "no computation: neither inline block nor `computation` path".to_string(),
621                );
622            }
623            if contract.has_redundant_inline {
624                issues.push("both `computation` path and inline block present".to_string());
625            }
626            for parameter in &contract.parameters {
627                if parameter.name.is_none() {
628                    issues.push("a parameter entry lacks `name`".to_string());
629                }
630            }
631            let mut path_checks = Vec::new();
632            for (field, raw) in contract.path_fields() {
633                let resolved = bundle.resolve_path_field(&concept.id, raw);
634                if resolved.is_none() {
635                    issues.push(format!("{field} does not resolve: {raw}"));
636                }
637                path_checks.push((field.to_string(), raw.to_string(), resolved));
638            }
639            let syntax = match (&contract.computation, contract.runtime.as_deref()) {
640                (ComputationSource::Inline(inline), runtime) => {
641                    let tag = inline
642                        .language
643                        .clone()
644                        .or_else(|| runtime.map(String::from));
645                    tag.map(|tag| check_syntax(&tag, &inline.code).map_err(|e| e.to_string()))
646                }
647                _ => None,
648            };
649            if let Some(Err(e)) = &syntax {
650                issues.push(format!("computation syntax: {e}"));
651            }
652            Some(ContractInfo {
653                id: concept.id.clone(),
654                title: concept.display_title(),
655                contract,
656                path_checks,
657                syntax,
658                issues,
659            })
660        })
661        .collect()
662}