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