Skip to main content

fd_core/
score.rs

1//! Comprehensibility Score (R4.21)
2//!
3//! Computes a 0–100 score measuring how easily AI agents can understand
4//! an FD document. Five metrics, each worth 0–20 points.
5
6use crate::emitter::{ReadMode, emit_filtered};
7use crate::model::*;
8
9// ─── Types ───────────────────────────────────────────────────────────────
10
11/// A single metric in the comprehensibility report.
12#[derive(Debug, Clone)]
13pub struct MetricScore {
14    /// Short metric name (e.g. "semantic_naming").
15    pub name: &'static str,
16    /// Human-readable label for UI.
17    pub label: &'static str,
18    /// Score 0–20.
19    pub score: u8,
20    /// One-line improvement suggestion (empty if score == 20).
21    pub suggestion: String,
22}
23
24/// Full comprehensibility report for a document.
25#[derive(Debug, Clone)]
26pub struct ScoreReport {
27    /// Total score 0–100.
28    pub total: u8,
29    /// Per-metric breakdown.
30    pub metrics: Vec<MetricScore>,
31}
32
33// ─── Public API ──────────────────────────────────────────────────────────
34
35/// Compute the comprehensibility score for a scene graph.
36#[must_use]
37pub fn compute_score(graph: &SceneGraph) -> ScoreReport {
38    let metrics = vec![
39        metric_semantic_naming(graph),
40        metric_doc_comment_density(graph),
41        metric_style_reuse(graph),
42        metric_edge_default_coverage(graph),
43        metric_token_efficiency(graph),
44    ];
45    let total: u8 = metrics.iter().map(|m| m.score).sum::<u8>().min(100);
46    ScoreReport { total, metrics }
47}
48
49// ─── Metric 1: Semantic Naming (0–20) ────────────────────────────────────
50
51/// Ratio of non-anonymous `@id`s to total node count.
52fn metric_semantic_naming(graph: &SceneGraph) -> MetricScore {
53    let mut total = 0u32;
54    let mut semantic = 0u32;
55
56    for idx in graph.graph.node_indices() {
57        let node = &graph.graph[idx];
58        if matches!(node.kind, NodeKind::Root) {
59            continue;
60        }
61        total += 1;
62        if !is_anonymous_id(node.id.as_str()) {
63            semantic += 1;
64        }
65    }
66
67    let ratio = if total == 0 {
68        1.0
69    } else {
70        semantic as f32 / total as f32
71    };
72    let score = (ratio * 20.0).round() as u8;
73
74    let suggestion = if score < 20 {
75        let anon_count = total - semantic;
76        format!(
77            "Rename {anon_count} anonymous node(s) to semantic names (e.g. @login_btn instead of @_rect_0)"
78        )
79    } else {
80        String::new()
81    };
82
83    MetricScore {
84        name: "semantic_naming",
85        label: "Semantic Naming",
86        score,
87        suggestion,
88    }
89}
90
91/// Check if an ID matches the auto-generated `_kind_N` pattern.
92fn is_anonymous_id(id: &str) -> bool {
93    let prefixes = [
94        "_rect_",
95        "_ellipse_",
96        "_text_",
97        "_group_",
98        "_path_",
99        "_frame_",
100        "_generic_",
101        "_edge_",
102        "_image_",
103    ];
104    prefixes.iter().any(|p| id.starts_with(p))
105}
106
107// ─── Metric 2: Doc-Comment Density (0–20) ────────────────────────────────
108
109/// Ratio of nodes that have comments (manual `#` or `[auto]` generated).
110fn metric_doc_comment_density(graph: &SceneGraph) -> MetricScore {
111    let mut total = 0u32;
112    let mut commented = 0u32;
113
114    for idx in graph.graph.node_indices() {
115        let node = &graph.graph[idx];
116        if matches!(node.kind, NodeKind::Root) {
117            continue;
118        }
119        total += 1;
120        if !node.comments.is_empty() || node.spec.is_some() {
121            commented += 1;
122        }
123    }
124
125    let ratio = if total == 0 {
126        1.0
127    } else {
128        commented as f32 / total as f32
129    };
130    let score = (ratio * 20.0).round() as u8;
131
132    let suggestion = if score < 20 {
133        let uncommented = total - commented;
134        format!("Add comments or notes to {uncommented} undocumented node(s)")
135    } else {
136        String::new()
137    };
138
139    MetricScore {
140        name: "doc_comment_density",
141        label: "Documentation",
142        score,
143        suggestion,
144    }
145}
146
147// ─── Metric 3: Style Reuse (0–20) ────────────────────────────────────────
148
149/// Ratio of styled nodes using `use:` references vs inline styles.
150fn metric_style_reuse(graph: &SceneGraph) -> MetricScore {
151    let mut styled_total = 0u32;
152    let mut using_refs = 0u32;
153
154    for idx in graph.graph.node_indices() {
155        let node = &graph.graph[idx];
156        if matches!(node.kind, NodeKind::Root) {
157            continue;
158        }
159        let has_inline = has_inline_styles(&node.props);
160        let has_refs = !node.use_styles.is_empty();
161
162        if has_inline || has_refs {
163            styled_total += 1;
164            if has_refs {
165                using_refs += 1;
166            }
167        }
168    }
169
170    let ratio = if styled_total == 0 {
171        1.0 // no styled nodes = perfect (nothing to improve)
172    } else {
173        using_refs as f32 / styled_total as f32
174    };
175    let score = (ratio * 20.0).round() as u8;
176
177    let suggestion = if score < 20 && styled_total > 0 {
178        let inline_only = styled_total - using_refs;
179        format!(
180            "Extract inline styles from {inline_only} node(s) into reusable `style {{ }}` blocks"
181        )
182    } else {
183        String::new()
184    };
185
186    MetricScore {
187        name: "style_reuse",
188        label: "Style Reuse",
189        score,
190        suggestion,
191    }
192}
193
194/// Check if a Properties struct has any non-default inline styles.
195fn has_inline_styles(props: &Properties) -> bool {
196    props.fill.is_some()
197        || props.stroke.is_some()
198        || props.font.is_some()
199        || props.corner_radius.is_some()
200        || props.opacity.is_some()
201        || props.shadow.is_some()
202}
203
204// ─── Metric 4: Edge Default Coverage (0–20) ──────────────────────────────
205
206/// Ratio of edges whose properties match the `edge_defaults {}` block.
207fn metric_edge_default_coverage(graph: &SceneGraph) -> MetricScore {
208    let total_edges = graph.edges.len() as u32;
209
210    if total_edges == 0 {
211        return MetricScore {
212            name: "edge_default_coverage",
213            label: "Edge Defaults",
214            score: 20,
215            suggestion: String::new(),
216        };
217    }
218
219    // If no edge_defaults defined, score is 0 when edges exist
220    let defaults = match &graph.edge_defaults {
221        Some(d) => d,
222        None => {
223            return MetricScore {
224                name: "edge_default_coverage",
225                label: "Edge Defaults",
226                score: 0,
227                suggestion: format!(
228                    "Add an `edge_defaults {{ }}` block to reduce repetition across {total_edges} edge(s)"
229                ),
230            };
231        }
232    };
233
234    let mut matching = 0u32;
235    for edge in &graph.edges {
236        if edge_matches_defaults(edge, defaults) {
237            matching += 1;
238        }
239    }
240
241    let ratio = matching as f32 / total_edges as f32;
242    let score = (ratio * 20.0).round() as u8;
243
244    let suggestion = if score < 20 {
245        let non_matching = total_edges - matching;
246        format!(
247            "{non_matching} edge(s) override defaults — consider updating `edge_defaults` to cover common patterns"
248        )
249    } else {
250        String::new()
251    };
252
253    MetricScore {
254        name: "edge_default_coverage",
255        label: "Edge Defaults",
256        score,
257        suggestion,
258    }
259}
260
261/// Check if an edge's visual properties match (or inherit from) the document's edge_defaults.
262///
263/// The parser always assigns a built-in default stroke to edges that don't
264/// explicitly specify one (rgba(0.42, 0.44, 0.5, 1.0) at width 1.5).
265/// We detect this to distinguish "user explicitly set stroke" from "parser default".
266fn edge_matches_defaults(edge: &Edge, defaults: &EdgeDefaults) -> bool {
267    // Stroke: if edge has the parser's built-in default, treat as "not explicitly set"
268    let stroke_ok = match &edge.props.stroke {
269        Some(s) if is_parser_default_stroke(s) => true, // inherited, matches any default
270        Some(es) => match &defaults.props.stroke {
271            Some(ds) => stroke_approx_eq(es, ds),
272            None => false, // edge has explicit stroke but no default defined
273        },
274        None => true, // no stroke at all, inherits default
275    };
276
277    // Arrow: ArrowKind::None means "not explicitly set" (parser default)
278    let arrow_ok = match defaults.arrow {
279        Some(da) => edge.arrow == da || edge.arrow == ArrowKind::None,
280        None => true,
281    };
282
283    // Curve: CurveKind::Straight means "not explicitly set" (parser default)
284    let curve_ok = match defaults.curve {
285        Some(dc) => edge.curve == dc || edge.curve == CurveKind::Straight,
286        None => true,
287    };
288
289    stroke_ok && arrow_ok && curve_ok
290}
291
292/// Check if a stroke matches the parser's built-in default for edges.
293/// Parser assigns rgba(0.42, 0.44, 0.5, 1.0) at width 1.5 when no stroke is specified.
294fn is_parser_default_stroke(stroke: &Stroke) -> bool {
295    if let Paint::Solid(c) = &stroke.paint {
296        (c.r - 0.42).abs() < 0.01
297            && (c.g - 0.44).abs() < 0.01
298            && (c.b - 0.5).abs() < 0.01
299            && (stroke.width - 1.5).abs() < 0.01
300    } else {
301        false
302    }
303}
304
305/// Approximate equality for strokes.
306fn stroke_approx_eq(a: &Stroke, b: &Stroke) -> bool {
307    (a.width - b.width).abs() < 0.001 && a.paint == b.paint
308}
309
310// ─── Metric 5: Token Efficiency (0–20) ───────────────────────────────────
311
312/// Ratio of structure-only tokens to full tokens. A well-structured
313/// document should have a smaller structure representation relative to full.
314fn metric_token_efficiency(graph: &SceneGraph) -> MetricScore {
315    let full = emit_filtered(graph, ReadMode::Full);
316    let structure = emit_filtered(graph, ReadMode::Structure);
317
318    let full_tokens = estimate_tokens(&full);
319    let structure_tokens = estimate_tokens(&structure);
320
321    if full_tokens == 0 {
322        return MetricScore {
323            name: "token_efficiency",
324            label: "Token Efficiency",
325            score: 20,
326            suggestion: String::new(),
327        };
328    }
329
330    // The ratio measures how much overhead full mode adds.
331    // A fully efficient doc has structure ≈ 30% of full (lots of styling/dims).
332    // Score = how close structure/full is to the ideal ratio of ~0.3.
333    let ratio = structure_tokens as f32 / full_tokens as f32;
334
335    // Score: if ratio <= 0.5, full score (structure is lean).
336    // If ratio > 0.5, penalize linearly. Ratio of 1.0 = 0 score.
337    let score = if ratio <= 0.5 {
338        20
339    } else {
340        let penalty = ((ratio - 0.5) / 0.5).min(1.0);
341        ((1.0 - penalty) * 20.0).round() as u8
342    };
343
344    let suggestion = if score < 20 {
345        "Add more styling, dimensions, and annotations to enrich the document beyond bare structure"
346            .to_string()
347    } else {
348        String::new()
349    };
350
351    MetricScore {
352        name: "token_efficiency",
353        label: "Token Efficiency",
354        score,
355        suggestion,
356    }
357}
358
359/// Rough token count estimation (split on whitespace + punctuation).
360fn estimate_tokens(text: &str) -> usize {
361    // Simple heuristic: ~4 characters per token (GPT-style tokenization)
362    let char_count = text.len();
363    char_count.div_ceil(4)
364}
365
366// ─── Tests ───────────────────────────────────────────────────────────────
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use crate::parser::parse_document;
372
373    #[test]
374    fn score_empty_document() {
375        let graph = SceneGraph::new();
376        let report = compute_score(&graph);
377        assert_eq!(
378            report.total, 100,
379            "empty document should have perfect score"
380        );
381    }
382
383    #[test]
384    fn score_perfect_document() {
385        let input = r#"
386style card {
387  fill: #FFF
388  corner: 8
389}
390
391edge_defaults {
392  stroke: #333 2
393  arrow: end
394}
395
396# Main card component
397rect @hero_card {
398  w: 300 h: 200
399  use: card
400  note "Primary hero element"
401}
402
403# Secondary element
404text @subtitle "Welcome" {
405  use: card
406}
407
408edge @flow_1 {
409  from: @hero_card
410  to: @subtitle
411}
412"#;
413        let graph = parse_document(input).unwrap();
414        let report = compute_score(&graph);
415        // All nodes are semantic, have comments/notes, use style refs, edge matches defaults
416        assert!(
417            report.total >= 80,
418            "well-structured document should score >= 80, got {}",
419            report.total
420        );
421    }
422
423    #[test]
424    fn score_anonymous_ids() {
425        // A document with only auto-generated IDs
426        let input = "rect { w: 100 h: 50 }\nellipse { w: 80 h: 80 }\n";
427        let graph = parse_document(input).unwrap();
428        let report = compute_score(&graph);
429        let naming = report
430            .metrics
431            .iter()
432            .find(|m| m.name == "semantic_naming")
433            .unwrap();
434        assert_eq!(
435            naming.score, 0,
436            "anonymous-only IDs should score 0 on naming"
437        );
438        assert!(!naming.suggestion.is_empty());
439    }
440
441    #[test]
442    fn score_no_style_reuse() {
443        let input = r#"
444rect @btn {
445  w: 100 h: 50
446  fill: #FF0000
447  corner: 8
448}
449rect @card {
450  w: 200 h: 100
451  fill: #00FF00
452  corner: 12
453}
454"#;
455        let graph = parse_document(input).unwrap();
456        let report = compute_score(&graph);
457        let reuse = report
458            .metrics
459            .iter()
460            .find(|m| m.name == "style_reuse")
461            .unwrap();
462        assert_eq!(reuse.score, 0, "inline-only styles should score 0 on reuse");
463    }
464
465    #[test]
466    fn score_edge_defaults_coverage() {
467        let input = r#"
468edge_defaults {
469  stroke: #333 2
470  arrow: end
471}
472
473rect @a { w: 50 h: 50 }
474rect @b { w: 50 h: 50 }
475
476edge @e1 {
477  from: @a
478  to: @b
479}
480
481edge @e2 {
482  from: @a
483  to: @b
484  stroke: #FF0000 4
485}
486"#;
487        let graph = parse_document(input).unwrap();
488        let report = compute_score(&graph);
489        let coverage = report
490            .metrics
491            .iter()
492            .find(|m| m.name == "edge_default_coverage")
493            .unwrap();
494        // 1 of 2 edges matches defaults = 50% = 10/20
495        assert_eq!(
496            coverage.score, 10,
497            "half matching edges should score 10, got {}",
498            coverage.score
499        );
500    }
501
502    #[test]
503    fn score_mixed_document() {
504        let input = r#"
505style primary {
506  fill: #007AFF
507}
508
509# Header section
510rect @header {
511  w: 800 h: 60
512  use: primary
513  note "Main navigation bar"
514}
515
516rect { w: 100 h: 50 fill: #FF0000 }
517"#;
518        let graph = parse_document(input).unwrap();
519        let report = compute_score(&graph);
520        // Mixed: one semantic + one anonymous, one with style ref + one inline
521        assert!(
522            report.total > 20 && report.total < 90,
523            "mixed document should score moderately, got {}",
524            report.total
525        );
526    }
527}