Skip to main content

faf_kernel/
score.rs

1//! Mk4 Championship Engine — 33-slot scoring.
2//!
3//! The kernel knows **33 slots** and three states (Populated, Empty,
4//! Slotignored). Score = populated ÷ active, where active = 33 − slotignored.
5//!
6//! It knows nothing about owner, intent, users, or app_type. A complex
7//! enterprise repo and a minimal profile are the *same object* to it: a fill
8//! pattern over 33 slots. `app_type` is a **generation-time** concern — it
9//! decides which slots get written `slotignored` — and the kernel only ever
10//! reads the markers. That agnosticism is the universality: same as a JPEG
11//! not knowing it's a cat or a CT scan.
12//!
13//! Grok Souls and other `.fafm` memory artifacts are a *different format*
14//! (Memory, not Context) and are not scored here at all — by spec, memory is
15//! not graded.
16//!
17//! Canonical with `~/FAF/cli/src/core/slots.ts` (.faf-33 / Mk4) and
18//! `tiers.ts` (Trophy 🏆 · ★ ◆ ◇ ● ● ○ ♡ — no medal emoji).
19
20use serde_yaml_ng::Value;
21
22/// The three technical states of a FAF slot.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum SlotState {
25    /// Missing or placeholder — counts against the score.
26    Empty,
27    /// Valid, project-specific data.
28    Populated,
29    /// Explicitly marked not-applicable — excluded from the active denominator.
30    Slotignored,
31}
32
33/// The total number of FAF slots. The kernel always scores against all 33;
34/// "21-base" files simply carry the 12 enterprise slots as `slotignored`.
35pub const TOTAL_SLOTS: u32 = 33;
36
37/// The result of an Mk4 scoring run.
38#[derive(Debug, Clone)]
39pub struct Mk4Result {
40    /// 0–100.
41    pub score: u32,
42    /// Canonical tier name: TROPHY, GOLD, SILVER, BRONZE, GREEN, YELLOW, RED, WHITE.
43    pub tier: String,
44    /// Slots filled with valid, project-specific data (the numerator).
45    pub populated: u32,
46    /// Slots explicitly marked not-applicable (excluded from the denominator).
47    pub ignored: u32,
48    /// Active denominator: `total − ignored`.
49    pub active: u32,
50    /// Always 33.
51    pub total: u32,
52    /// Per-slot breakdown: slot name → its [`SlotState`].
53    pub slots: Vec<(String, SlotState)>,
54}
55
56impl Mk4Result {
57    /// Export as a JSON string (stable shape across all FAF engines).
58    pub fn to_json(&self) -> String {
59        let mut slots_json = String::from("{");
60        for (i, (name, state)) in self.slots.iter().enumerate() {
61            if i > 0 {
62                slots_json.push(',');
63            }
64            let state_str = match state {
65                SlotState::Populated => "populated",
66                SlotState::Empty => "empty",
67                SlotState::Slotignored => "slotignored",
68            };
69            slots_json.push_str(&format!("\"{}\":\"{}\"", name, state_str));
70        }
71        slots_json.push('}');
72
73        format!(
74            r#"{{"score":{},"tier":"{}","populated":{},"empty":{},"ignored":{},"active":{},"total":{},"slots":{}}}"#,
75            self.score,
76            self.tier,
77            self.populated,
78            self.total - self.populated - self.ignored,
79            self.ignored,
80            self.active,
81            self.total,
82            slots_json
83        )
84    }
85}
86
87/// Score a `.faf` YAML document against the 33-slot model.
88pub fn score(yaml: &str) -> Result<Mk4Result, String> {
89    Mk4Scorer::new().calculate(yaml)
90}
91
92/// The Mk4 scoring engine.
93#[derive(Debug, Default)]
94pub struct Mk4Scorer;
95
96impl Mk4Scorer {
97    /// Create a new scorer.
98    pub fn new() -> Self {
99        Self
100    }
101
102    /// Calculate the official FAF score from YAML content.
103    pub fn calculate(&self, yaml: &str) -> Result<Mk4Result, String> {
104        let doc: Value =
105            serde_yaml_ng::from_str(yaml).map_err(|e| format!("YAML parse error: {}", e))?;
106
107        let mut populated: u32 = 0;
108        let mut ignored: u32 = 0;
109
110        let mut slots: Vec<(String, SlotState)> = Vec::with_capacity(SLOTS.len());
111        for slot_path in SLOTS {
112            let state = slot_state(&doc, slot_path);
113            match state {
114                SlotState::Populated => populated += 1,
115                SlotState::Slotignored => ignored += 1,
116                SlotState::Empty => (),
117            }
118            slots.push((slot_path.to_string(), state));
119        }
120
121        let active = TOTAL_SLOTS - ignored;
122        let score = if active == 0 {
123            0.0
124        } else {
125            (populated as f64 / active as f64) * 100.0
126        };
127        let score_rounded = score.round() as u32;
128
129        Ok(Mk4Result {
130            score: score_rounded,
131            tier: tier_name(score_rounded).to_string(),
132            populated,
133            ignored,
134            active,
135            total: TOTAL_SLOTS,
136            slots,
137        })
138    }
139}
140
141/// The Universal DNA Map — the 33 canonical Mk4 slot paths, in order.
142/// The 6 renamed slots (framework/css/state/api/db/pkg_manager) accept their
143/// legacy aliases on read; see `legacy_alias_for`.
144const SLOTS: &[&str] = &[
145    // Project Meta (3)
146    "project.name",
147    "project.goal",
148    "project.main_language",
149    // Human Context (6)
150    "human_context.who",
151    "human_context.what",
152    "human_context.why",
153    "human_context.where",
154    "human_context.when",
155    "human_context.how",
156    // Frontend Stack (4)
157    "stack.framework",
158    "stack.css",
159    "stack.ui_library",
160    "stack.state",
161    // Backend Stack (5)
162    "stack.backend",
163    "stack.api",
164    "stack.runtime",
165    "stack.db",
166    "stack.connection",
167    // Universal Stack (3)
168    "stack.hosting",
169    "stack.build",
170    "stack.cicd",
171    // Enterprise Infra (5)
172    "stack.monorepo_tool",
173    "stack.pkg_manager",
174    "stack.workspaces",
175    "monorepo.packages_count",
176    "monorepo.build_orchestrator",
177    // Enterprise App (4)
178    "stack.admin",
179    "stack.cache",
180    "stack.search",
181    "stack.storage",
182    // Enterprise Ops (3)
183    "monorepo.versioning_strategy",
184    "monorepo.shared_configs",
185    "monorepo.remote_cache",
186];
187
188/// Legacy alias for a Mk4 canonical slot path — backward compat so existing
189/// .faf files (with legacy keys) keep scoring correctly.
190fn legacy_alias_for(canonical: &str) -> Option<&'static str> {
191    match canonical {
192        "stack.framework" => Some("stack.frontend"),
193        "stack.css" => Some("stack.css_framework"),
194        "stack.state" => Some("stack.state_management"),
195        "stack.api" => Some("stack.api_type"),
196        "stack.db" => Some("stack.database"),
197        "stack.pkg_manager" => Some("stack.package_manager"),
198        _ => None,
199    }
200}
201
202/// Determine the state of a specific slot — canonical path first, legacy
203/// alias fallback.
204fn slot_state(doc: &Value, path: &str) -> SlotState {
205    let state = walk_path_state(doc, path);
206    if matches!(state, SlotState::Empty) {
207        if let Some(legacy) = legacy_alias_for(path) {
208            return walk_path_state(doc, legacy);
209        }
210    }
211    state
212}
213
214/// Walk a dotted path in the YAML doc and classify the value's state.
215fn walk_path_state(doc: &Value, path: &str) -> SlotState {
216    let mut current = doc;
217    for part in path.split('.') {
218        if let Some(next) = current.get(Value::String(part.to_string())) {
219            current = next;
220        } else {
221            return SlotState::Empty;
222        }
223    }
224
225    match current {
226        Value::String(s) => {
227            let s = s.trim();
228            if s == "slotignored" {
229                SlotState::Slotignored
230            } else if is_valid_populated_string(s) {
231                SlotState::Populated
232            } else {
233                SlotState::Empty
234            }
235        }
236        Value::Number(_) | Value::Bool(_) => SlotState::Populated,
237        Value::Sequence(seq) => {
238            if !seq.is_empty() {
239                SlotState::Populated
240            } else {
241                SlotState::Empty
242            }
243        }
244        Value::Mapping(map) => {
245            if !map.is_empty() {
246                SlotState::Populated
247            } else {
248                SlotState::Empty
249            }
250        }
251        _ => SlotState::Empty,
252    }
253}
254
255/// Rule 1: Placeholder Rejection (Honest Truth).
256fn is_valid_populated_string(s: &str) -> bool {
257    let placeholders = [
258        "describe your project goal",
259        "development teams",
260        "cloud platform",
261        "null",
262        "none",
263        "unknown",
264        "n/a",
265        "not applicable",
266    ];
267
268    !s.is_empty() && !placeholders.contains(&s.to_lowercase().as_str())
269}
270
271/// Canonical tier name for a score — source of truth: tiers.ts.
272pub fn tier_name(score: u32) -> &'static str {
273    if score >= 100 {
274        "TROPHY"
275    } else if score >= 99 {
276        "GOLD"
277    } else if score >= 95 {
278        "SILVER"
279    } else if score >= 85 {
280        "BRONZE"
281    } else if score >= 70 {
282        "GREEN"
283    } else if score >= 55 {
284        "YELLOW"
285    } else if score >= 1 {
286        "RED"
287    } else {
288        "WHITE"
289    }
290}
291
292/// Canonical tier symbol — Trophy 🏆 is the ONLY emoji; sub-Trophy tiers use
293/// clean Unicode geometric symbols. The medal-emoji ladder is history.
294pub fn tier_symbol(score: u32) -> &'static str {
295    if score >= 100 {
296        "🏆"
297    } else if score >= 99 {
298        "★"
299    } else if score >= 95 {
300        "◆"
301    } else if score >= 85 {
302        "◇"
303    } else if score >= 55 {
304        // GREEN (≥70) and YELLOW (≥55) share the ● glyph; the CLI
305        // differentiates them by color (bold vs dim), which the kernel omits.
306        "●"
307    } else if score >= 1 {
308        "○"
309    } else {
310        "♡"
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    /// A cli-shaped file: of the 33 slots, the 21 non-cli ones are slotignored,
319    /// leaving 12 active — all populated → Trophy. This is the always-33 model:
320    /// the universe is fixed; the file's markers set what's active.
321    const CLI_TROPHY: &str = r#"
322project:
323  name: my-cli
324  goal: Ship fast
325  main_language: Rust
326human_context:
327  who: Devs
328  what: CLI tool
329  why: Speed
330  where: crates.io
331  when: Now
332  how: Cargo
333stack:
334  framework: slotignored
335  css: slotignored
336  ui_library: slotignored
337  state: slotignored
338  backend: slotignored
339  api: slotignored
340  runtime: slotignored
341  db: slotignored
342  connection: slotignored
343  hosting: GitHub
344  build: cargo
345  cicd: GitHub Actions
346  monorepo_tool: slotignored
347  pkg_manager: slotignored
348  workspaces: slotignored
349  admin: slotignored
350  cache: slotignored
351  search: slotignored
352  storage: slotignored
353monorepo:
354  packages_count: slotignored
355  build_orchestrator: slotignored
356  versioning_strategy: slotignored
357  shared_configs: slotignored
358  remote_cache: slotignored
359"#;
360
361    #[test]
362    fn empty_yaml_scores_zero_against_33() {
363        let result = score("empty: true").unwrap();
364        assert_eq!(result.score, 0);
365        assert_eq!(result.populated, 0);
366        assert_eq!(result.total, 33);
367        assert_eq!(result.active, 33); // nothing slotignored
368        assert_eq!(result.tier, "WHITE");
369    }
370
371    #[test]
372    fn invalid_yaml_returns_error() {
373        assert!(score("invalid: yaml: [").is_err());
374    }
375
376    #[test]
377    fn always_scores_against_33() {
378        // No app_type anywhere; the kernel never branches on one.
379        for yaml in [
380            "project:\n  name: x\n",
381            "app_type: enterprise\nproject:\n  name: y\n",
382        ] {
383            let result = score(yaml).unwrap();
384            assert_eq!(result.total, 33);
385            assert_eq!(result.slots.len(), 33);
386        }
387    }
388
389    #[test]
390    fn slotignored_sets_the_active_denominator() {
391        // 12 active (21 slotignored), all populated → Trophy. The kernel needs
392        // no idea this is a "cli" — the markers do all the work.
393        let result = score(CLI_TROPHY).unwrap();
394        assert_eq!(result.ignored, 21);
395        assert_eq!(result.active, 12);
396        assert_eq!(result.populated, 12);
397        assert_eq!(result.score, 100);
398        assert_eq!(result.tier, "TROPHY");
399    }
400
401    #[test]
402    fn missing_slot_counts_against_score_unlike_slotignored() {
403        // Same as CLI_TROPHY but with one active slot (commands/build) dropped:
404        // it's now Empty (missing), not Slotignored — so it counts in the
405        // denominator and the score is honest about the gap.
406        let yaml = CLI_TROPHY.replace("  build: cargo\n", "");
407        let result = score(&yaml).unwrap();
408        assert_eq!(result.ignored, 21);
409        assert_eq!(result.active, 12); // still 12 active (build is empty, not ignored)
410        assert_eq!(result.populated, 11);
411        assert!(result.score < 100); // honest: 11/12
412    }
413
414    #[test]
415    fn legacy_aliases_score() {
416        let yaml = r#"
417project:
418  name: x
419stack:
420  frontend: Svelte
421  css_framework: Tailwind
422  state_management: Stores
423  api_type: REST
424  database: Postgres
425  package_manager: pnpm
426"#;
427        let result = score(yaml).unwrap();
428        let populated: Vec<&str> = result
429            .slots
430            .iter()
431            .filter(|(_, s)| *s == SlotState::Populated)
432            .map(|(n, _)| n.as_str())
433            .collect();
434        assert!(populated.contains(&"stack.framework"));
435        assert!(populated.contains(&"stack.css"));
436        assert!(populated.contains(&"stack.state"));
437        assert!(populated.contains(&"stack.api"));
438        assert!(populated.contains(&"stack.db"));
439        assert!(populated.contains(&"stack.pkg_manager"));
440    }
441
442    #[test]
443    fn placeholders_rejected() {
444        let yaml = "project:\n  name: unknown\n  goal: n/a\n";
445        let result = score(yaml).unwrap();
446        assert_eq!(result.populated, 0);
447    }
448
449    #[test]
450    fn tier_ladder_canonical() {
451        assert_eq!(tier_name(100), "TROPHY");
452        assert_eq!(tier_name(99), "GOLD");
453        assert_eq!(tier_name(95), "SILVER");
454        assert_eq!(tier_name(85), "BRONZE");
455        assert_eq!(tier_name(70), "GREEN");
456        assert_eq!(tier_name(55), "YELLOW");
457        assert_eq!(tier_name(1), "RED");
458        assert_eq!(tier_name(0), "WHITE");
459        // Trophy is the ONLY emoji; the medal ladder is history.
460        assert_eq!(tier_symbol(100), "🏆");
461        assert_eq!(tier_symbol(99), "★");
462        assert_eq!(tier_symbol(95), "◆");
463        assert_eq!(tier_symbol(85), "◇");
464        assert_eq!(tier_symbol(0), "♡");
465    }
466
467    #[test]
468    fn full_33_all_populated_is_trophy() {
469        // Every slot populated, nothing ignored → 33/33 = Trophy.
470        let mut yaml = String::from("project:\n  name: x\n  goal: y\n  main_language: Rust\n");
471        yaml.push_str("human_context:\n");
472        for w in ["who", "what", "why", "where", "when", "how"] {
473            yaml.push_str(&format!("  {}: v\n", w));
474        }
475        yaml.push_str("stack:\n");
476        for s in [
477            "framework",
478            "css",
479            "ui_library",
480            "state",
481            "backend",
482            "api",
483            "runtime",
484            "db",
485            "connection",
486            "hosting",
487            "build",
488            "cicd",
489            "monorepo_tool",
490            "pkg_manager",
491            "workspaces",
492            "admin",
493            "cache",
494            "search",
495            "storage",
496        ] {
497            yaml.push_str(&format!("  {}: v\n", s));
498        }
499        yaml.push_str("monorepo:\n");
500        for m in [
501            "packages_count",
502            "build_orchestrator",
503            "versioning_strategy",
504            "shared_configs",
505            "remote_cache",
506        ] {
507            yaml.push_str(&format!("  {}: v\n", m));
508        }
509        let result = score(&yaml).unwrap();
510        assert_eq!(result.populated, 33);
511        assert_eq!(result.ignored, 0);
512        assert_eq!(result.active, 33);
513        assert_eq!(result.score, 100);
514        assert_eq!(result.tier, "TROPHY");
515    }
516
517    #[test]
518    fn to_json_shape() {
519        let json = score("project:\n  name: x\n").unwrap().to_json();
520        assert!(json.contains("\"score\":"));
521        assert!(json.contains("\"total\":33"));
522        assert!(json.contains("\"tier\":\"RED\""));
523        assert!(json.contains("\"project.name\":\"populated\""));
524    }
525}