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        "tbd",
265        "todo",
266        "n/a",
267        "not applicable",
268    ];
269
270    !s.is_empty() && !placeholders.contains(&s.to_lowercase().as_str())
271}
272
273/// Canonical tier name for a score — source of truth: tiers.ts.
274pub fn tier_name(score: u32) -> &'static str {
275    if score >= 100 {
276        "TROPHY"
277    } else if score >= 99 {
278        "GOLD"
279    } else if score >= 95 {
280        "SILVER"
281    } else if score >= 85 {
282        "BRONZE"
283    } else if score >= 70 {
284        "GREEN"
285    } else if score >= 55 {
286        "YELLOW"
287    } else if score >= 1 {
288        "RED"
289    } else {
290        "WHITE"
291    }
292}
293
294/// Canonical tier symbol — Trophy 🏆 is the ONLY emoji; sub-Trophy tiers use
295/// clean Unicode geometric symbols. The medal-emoji ladder is history.
296pub fn tier_symbol(score: u32) -> &'static str {
297    if score >= 100 {
298        "🏆"
299    } else if score >= 99 {
300        "★"
301    } else if score >= 95 {
302        "◆"
303    } else if score >= 85 {
304        "◇"
305    } else if score >= 55 {
306        // GREEN (≥70) and YELLOW (≥55) share the ● glyph; the CLI
307        // differentiates them by color (bold vs dim), which the kernel omits.
308        "●"
309    } else if score >= 1 {
310        "○"
311    } else {
312        "♡"
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    /// A cli-shaped file: of the 33 slots, the 21 non-cli ones are slotignored,
321    /// leaving 12 active — all populated → Trophy. This is the always-33 model:
322    /// the universe is fixed; the file's markers set what's active.
323    const CLI_TROPHY: &str = r#"
324project:
325  name: my-cli
326  goal: Ship fast
327  main_language: Rust
328human_context:
329  who: Devs
330  what: CLI tool
331  why: Speed
332  where: crates.io
333  when: Now
334  how: Cargo
335stack:
336  framework: slotignored
337  css: slotignored
338  ui_library: slotignored
339  state: slotignored
340  backend: slotignored
341  api: slotignored
342  runtime: slotignored
343  db: slotignored
344  connection: slotignored
345  hosting: GitHub
346  build: cargo
347  cicd: GitHub Actions
348  monorepo_tool: slotignored
349  pkg_manager: slotignored
350  workspaces: slotignored
351  admin: slotignored
352  cache: slotignored
353  search: slotignored
354  storage: slotignored
355monorepo:
356  packages_count: slotignored
357  build_orchestrator: slotignored
358  versioning_strategy: slotignored
359  shared_configs: slotignored
360  remote_cache: slotignored
361"#;
362
363    #[test]
364    fn empty_yaml_scores_zero_against_33() {
365        let result = score("empty: true").unwrap();
366        assert_eq!(result.score, 0);
367        assert_eq!(result.populated, 0);
368        assert_eq!(result.total, 33);
369        assert_eq!(result.active, 33); // nothing slotignored
370        assert_eq!(result.tier, "WHITE");
371    }
372
373    #[test]
374    fn invalid_yaml_returns_error() {
375        assert!(score("invalid: yaml: [").is_err());
376    }
377
378    #[test]
379    fn always_scores_against_33() {
380        // No app_type anywhere; the kernel never branches on one.
381        for yaml in [
382            "project:\n  name: x\n",
383            "app_type: enterprise\nproject:\n  name: y\n",
384        ] {
385            let result = score(yaml).unwrap();
386            assert_eq!(result.total, 33);
387            assert_eq!(result.slots.len(), 33);
388        }
389    }
390
391    #[test]
392    fn slotignored_sets_the_active_denominator() {
393        // 12 active (21 slotignored), all populated → Trophy. The kernel needs
394        // no idea this is a "cli" — the markers do all the work.
395        let result = score(CLI_TROPHY).unwrap();
396        assert_eq!(result.ignored, 21);
397        assert_eq!(result.active, 12);
398        assert_eq!(result.populated, 12);
399        assert_eq!(result.score, 100);
400        assert_eq!(result.tier, "TROPHY");
401    }
402
403    #[test]
404    fn missing_slot_counts_against_score_unlike_slotignored() {
405        // Same as CLI_TROPHY but with one active slot (commands/build) dropped:
406        // it's now Empty (missing), not Slotignored — so it counts in the
407        // denominator and the score is honest about the gap.
408        let yaml = CLI_TROPHY.replace("  build: cargo\n", "");
409        let result = score(&yaml).unwrap();
410        assert_eq!(result.ignored, 21);
411        assert_eq!(result.active, 12); // still 12 active (build is empty, not ignored)
412        assert_eq!(result.populated, 11);
413        assert!(result.score < 100); // honest: 11/12
414    }
415
416    #[test]
417    fn legacy_aliases_score() {
418        let yaml = r#"
419project:
420  name: x
421stack:
422  frontend: Svelte
423  css_framework: Tailwind
424  state_management: Stores
425  api_type: REST
426  database: Postgres
427  package_manager: pnpm
428"#;
429        let result = score(yaml).unwrap();
430        let populated: Vec<&str> = result
431            .slots
432            .iter()
433            .filter(|(_, s)| *s == SlotState::Populated)
434            .map(|(n, _)| n.as_str())
435            .collect();
436        assert!(populated.contains(&"stack.framework"));
437        assert!(populated.contains(&"stack.css"));
438        assert!(populated.contains(&"stack.state"));
439        assert!(populated.contains(&"stack.api"));
440        assert!(populated.contains(&"stack.db"));
441        assert!(populated.contains(&"stack.pkg_manager"));
442    }
443
444    #[test]
445    fn placeholders_rejected() {
446        let yaml = "project:\n  name: unknown\n  goal: n/a\n";
447        let result = score(yaml).unwrap();
448        assert_eq!(result.populated, 0);
449    }
450
451    #[test]
452    fn tier_ladder_canonical() {
453        assert_eq!(tier_name(100), "TROPHY");
454        assert_eq!(tier_name(99), "GOLD");
455        assert_eq!(tier_name(95), "SILVER");
456        assert_eq!(tier_name(85), "BRONZE");
457        assert_eq!(tier_name(70), "GREEN");
458        assert_eq!(tier_name(55), "YELLOW");
459        assert_eq!(tier_name(1), "RED");
460        assert_eq!(tier_name(0), "WHITE");
461        // Trophy is the ONLY emoji; the medal ladder is history.
462        assert_eq!(tier_symbol(100), "🏆");
463        assert_eq!(tier_symbol(99), "★");
464        assert_eq!(tier_symbol(95), "◆");
465        assert_eq!(tier_symbol(85), "◇");
466        assert_eq!(tier_symbol(0), "♡");
467    }
468
469    #[test]
470    fn full_33_all_populated_is_trophy() {
471        // Every slot populated, nothing ignored → 33/33 = Trophy.
472        let mut yaml = String::from("project:\n  name: x\n  goal: y\n  main_language: Rust\n");
473        yaml.push_str("human_context:\n");
474        for w in ["who", "what", "why", "where", "when", "how"] {
475            yaml.push_str(&format!("  {}: v\n", w));
476        }
477        yaml.push_str("stack:\n");
478        for s in [
479            "framework",
480            "css",
481            "ui_library",
482            "state",
483            "backend",
484            "api",
485            "runtime",
486            "db",
487            "connection",
488            "hosting",
489            "build",
490            "cicd",
491            "monorepo_tool",
492            "pkg_manager",
493            "workspaces",
494            "admin",
495            "cache",
496            "search",
497            "storage",
498        ] {
499            yaml.push_str(&format!("  {}: v\n", s));
500        }
501        yaml.push_str("monorepo:\n");
502        for m in [
503            "packages_count",
504            "build_orchestrator",
505            "versioning_strategy",
506            "shared_configs",
507            "remote_cache",
508        ] {
509            yaml.push_str(&format!("  {}: v\n", m));
510        }
511        let result = score(&yaml).unwrap();
512        assert_eq!(result.populated, 33);
513        assert_eq!(result.ignored, 0);
514        assert_eq!(result.active, 33);
515        assert_eq!(result.score, 100);
516        assert_eq!(result.tier, "TROPHY");
517    }
518
519    #[test]
520    fn to_json_shape() {
521        let json = score("project:\n  name: x\n").unwrap().to_json();
522        assert!(json.contains("\"score\":"));
523        assert!(json.contains("\"total\":33"));
524        assert!(json.contains("\"tier\":\"RED\""));
525        assert!(json.contains("\"project.name\":\"populated\""));
526    }
527}