1use std::collections::{BTreeMap, BTreeSet};
2
3use serde_json::{Value, json};
4
5use crate::config::{pointer_f64, pointer_u64};
6use crate::model::{FileAnalysis, FolderAnalysis, parent_folders};
7
8const DEFAULT_COMPACT_MAX_TOKENS: u64 = 3_072;
9const DEFAULT_HEALTHY_MAX_TOKENS: u64 = 8_000;
10const DEFAULT_WARNING_MAX_TOKENS: u64 = 10_000;
11const DEFAULT_AGE_HALF_LIFE_DAYS: f64 = 180.0;
12const DEFAULT_CONTEXT_WEIGHT: f64 = 0.60;
13const DEFAULT_AGE_WEIGHT: f64 = 0.20;
14const DEFAULT_CHURN_WEIGHT: f64 = 0.20;
15
16fn round_to(value: f64, digits: i32) -> f64 {
17 let factor = 10_f64.powi(digits);
18 (value * factor).round_ties_even() / factor
19}
20
21fn p95(values: impl IntoIterator<Item = f64>) -> f64 {
22 let mut sorted: Vec<f64> = values.into_iter().collect();
23 if sorted.is_empty() {
24 return 1.0;
25 }
26 sorted.sort_by(f64::total_cmp);
27 let rank = ((sorted.len() as f64 * 0.95).ceil() as usize).max(1);
28 sorted[rank.saturating_sub(1).min(sorted.len() - 1)]
29}
30
31pub fn context_band_for_tokens(tokens: usize, config: &Value) -> String {
32 let compact_max = pointer_u64(
33 config,
34 "/tokenization/context_bands/compact_max_tokens",
35 DEFAULT_COMPACT_MAX_TOKENS,
36 ) as usize;
37 let healthy_max = pointer_u64(
38 config,
39 "/tokenization/context_bands/healthy_max_tokens",
40 DEFAULT_HEALTHY_MAX_TOKENS,
41 ) as usize;
42 let warning_max = pointer_u64(
43 config,
44 "/tokenization/context_bands/warning_max_tokens",
45 DEFAULT_WARNING_MAX_TOKENS,
46 ) as usize;
47 if tokens <= compact_max {
48 "compact"
49 } else if tokens <= healthy_max {
50 "healthy"
51 } else if tokens <= warning_max {
52 "warning"
53 } else {
54 "critical"
55 }
56 .to_string()
57}
58
59pub fn context_pressure_for_tokens(tokens: usize, config: &Value) -> f64 {
60 let warning_max = pointer_u64(
61 config,
62 "/tokenization/context_bands/warning_max_tokens",
63 DEFAULT_WARNING_MAX_TOKENS,
64 )
65 .max(1) as f64;
66 (tokens as f64 / warning_max).min(1.0)
67}
68
69pub fn slop_band_for_score(score: f64) -> String {
70 if score >= 85.0 {
71 "critical"
72 } else if score >= 65.0 {
73 "high"
74 } else if score >= 50.0 {
75 "moderate"
76 } else {
77 "low"
78 }
79 .to_string()
80}
81
82fn age_pressure(age_days: u64, config: &Value) -> f64 {
83 if age_days == 0 {
84 return 0.0;
85 }
86 let half_life = pointer_f64(
87 config,
88 "/history/age_half_life_days",
89 DEFAULT_AGE_HALF_LIFE_DAYS,
90 );
91 if half_life <= 0.0 {
92 return 1.0;
93 }
94 1.0 - 2_f64.powf(-(age_days as f64 / half_life))
95}
96
97fn reason_codes(record: &FileAnalysis) -> Vec<String> {
98 let mut reasons = Vec::new();
99 match record.context_band.as_str() {
100 "critical" => reasons.push("critical_token_cost".to_string()),
101 "warning" => reasons.push("high_token_cost".to_string()),
102 _ => {}
103 }
104 if record.age_days >= 180 {
105 reasons.push("old_file".to_string());
106 }
107 if record.revision_norm >= 0.8 {
108 reasons.push("high_revision_frequency".to_string());
109 }
110 if record.relative_churn_norm >= 0.8 {
111 reasons.push("high_relative_churn".to_string());
112 }
113 if record.age_days >= 180 && record.churn_pressure >= 0.6 {
114 reasons.push("old_and_volatile".to_string());
115 }
116 reasons
117}
118
119pub fn apply_scoring(records: &mut [FileAnalysis], config: &Value) {
125 let revision_p95 = p95(records.iter().map(|record| record.revisions_window as f64)).max(1.0);
126 let relative_churn_p95 = p95(records.iter().map(|record| record.relative_churn_window));
127 let relative_churn_denom = if relative_churn_p95 > 0.0 {
128 relative_churn_p95
129 } else {
130 1.0
131 };
132 let context_weight = pointer_f64(config, "/scoring/context_weight", DEFAULT_CONTEXT_WEIGHT);
133 let age_weight = pointer_f64(config, "/scoring/age_weight", DEFAULT_AGE_WEIGHT);
134 let churn_weight = pointer_f64(config, "/scoring/churn_weight", DEFAULT_CHURN_WEIGHT);
135
136 for record in records {
137 let raw_age_pressure = age_pressure(record.age_days, config);
138 let raw_revision_norm = (record.revisions_window as f64 / revision_p95).min(1.0);
139 let raw_relative_churn_norm =
140 (record.relative_churn_window / relative_churn_denom).min(1.0);
141 let raw_churn_pressure = 0.6 * raw_revision_norm + 0.4 * raw_relative_churn_norm;
142 let raw_score = 100.0
143 * (context_weight * record.context_pressure
144 + age_weight * raw_age_pressure
145 + churn_weight * raw_churn_pressure);
146
147 record.age_pressure = round_to(raw_age_pressure, 6);
148 record.revision_norm = round_to(raw_revision_norm, 6);
149 record.relative_churn_norm = round_to(raw_relative_churn_norm, 6);
150 record.churn_pressure = round_to(raw_churn_pressure, 6);
151 record.slop_score = round_to(raw_score, 1);
152 record.slop_band = slop_band_for_score(record.slop_score);
153 record.reason_codes = reason_codes(record);
154 }
155}
156
157pub fn folder_health_band(
158 direct_tokens: usize,
159 direct_file_count: usize,
160 config: &Value,
161) -> String {
162 let compact_max_tokens = pointer_u64(
163 config,
164 "/health/folder_bands/compact_max_direct_tokens",
165 31_999,
166 ) as usize;
167 let healthy_max_tokens = pointer_u64(
168 config,
169 "/health/folder_bands/healthy_max_direct_tokens",
170 128_000,
171 ) as usize;
172 let warning_max_tokens = pointer_u64(
173 config,
174 "/health/folder_bands/warning_max_direct_tokens",
175 256_000,
176 ) as usize;
177 let warning_max_files =
178 pointer_u64(config, "/health/folder_bands/warning_max_direct_files", 17) as usize;
179 let refactor_max_files = pointer_u64(
180 config,
181 "/health/folder_bands/refactor_required_max_direct_files",
182 37,
183 ) as usize;
184
185 if direct_tokens > warning_max_tokens || direct_file_count > refactor_max_files {
186 "refactor_required"
187 } else if direct_tokens > healthy_max_tokens || direct_file_count > warning_max_files {
188 "warning"
189 } else if direct_tokens > compact_max_tokens {
190 "healthy"
191 } else {
192 "compact"
193 }
194 .to_string()
195}
196
197fn direct_parent(path: &str) -> &str {
198 path.rsplit_once('/')
199 .map(|(parent, _)| parent)
200 .filter(|parent| !parent.is_empty())
201 .unwrap_or(".")
202}
203
204fn folder_classification(descendants: &[&FileAnalysis]) -> String {
205 let classifications: BTreeSet<&str> = descendants
206 .iter()
207 .map(|record| record.classification.as_str())
208 .collect();
209 if classifications.len() == 1 {
210 classifications
211 .into_iter()
212 .next()
213 .unwrap_or("other")
214 .to_string()
215 } else {
216 "mixed".to_string()
217 }
218}
219
220fn mean(values: impl IntoIterator<Item = f64>) -> f64 {
221 let values: Vec<f64> = values.into_iter().collect();
222 if values.is_empty() {
223 0.0
224 } else {
225 values.iter().sum::<f64>() / values.len() as f64
226 }
227}
228
229fn file_cost(record: &FileAnalysis, section: &str, key: &str) -> f64 {
230 record
231 .costs
232 .get(section)
233 .and_then(|value| value.get(key))
234 .and_then(Value::as_f64)
235 .unwrap_or_default()
236}
237
238fn folder_costs(descendants: &[&FileAnalysis], total_tokens: usize, config: &Value) -> Value {
239 let mut token_sizes: Vec<usize> = descendants.iter().map(|record| record.tokens).collect();
240 token_sizes.sort_unstable_by(|left, right| right.cmp(left));
241 let top_tokens = token_sizes.first().copied().unwrap_or(0);
242 json!({
243 "load": {
244 "file_token_count": top_tokens,
245 "folder_token_count": total_tokens,
246 "top_file_share": round_to(top_tokens as f64 / total_tokens.max(1) as f64, 6),
247 "top_3_file_share": round_to(
248 token_sizes.iter().take(3).sum::<usize>() as f64 / total_tokens.max(1) as f64,
249 6,
250 ),
251 "token_concentration_ratio": round_to(
252 top_tokens as f64 / total_tokens.max(1) as f64,
253 6,
254 ),
255 "context_band": context_band_for_tokens(total_tokens, config),
256 "load_pressure": round_to(context_pressure_for_tokens(total_tokens, config), 6),
257 },
258 "volatility": {
259 "commit_count_window": descendants
260 .iter()
261 .map(|record| record.revisions_window as f64)
262 .sum::<f64>(),
263 "recency_weighted_commits": round_to(
264 descendants.iter().map(|record| record.recency_weighted_commits).sum(),
265 6,
266 ),
267 "line_churn_window": round_to(
268 descendants
269 .iter()
270 .map(|record| record.line_churn_window as f64)
271 .sum(),
272 6,
273 ),
274 "token_churn_window": descendants
275 .iter()
276 .map(|record| record.token_churn_window)
277 .sum::<usize>(),
278 "relative_token_churn": round_to(
279 mean(descendants.iter().map(|record| {
280 record.token_churn_window as f64 / record.tokens.max(1) as f64
281 })),
282 6,
283 ),
284 "late_churn_spike": round_to(
285 mean(descendants.iter().map(|record| record.late_churn_spike)),
286 6,
287 ),
288 "volatility_pressure": round_to(
289 mean(descendants.iter().map(|record| record.churn_pressure)),
290 6,
291 ),
292 },
293 "coordination": {
294 "files_touched_per_change": round_to(
295 mean(descendants.iter().map(|record| {
296 file_cost(record, "coordination", "files_touched_per_change")
297 })),
298 6,
299 ),
300 "folders_touched_per_change": round_to(
301 mean(descendants.iter().map(|record| {
302 file_cost(record, "coordination", "folders_touched_per_change")
303 })),
304 6,
305 ),
306 "edit_hunks_per_change": round_to(
307 mean(descendants.iter().map(|record| {
308 file_cost(record, "coordination", "edit_hunks_per_change")
309 })),
310 6,
311 ),
312 "cochange_degree": round_to(
313 mean(descendants.iter().map(|record| {
314 file_cost(record, "coordination", "cochange_degree")
315 })),
316 6,
317 ),
318 "cochange_centrality": round_to(
319 mean(descendants.iter().map(|record| {
320 file_cost(record, "coordination", "cochange_centrality")
321 })),
322 6,
323 ),
324 "cross_folder_cochange_ratio": round_to(
325 mean(descendants.iter().map(|record| {
326 file_cost(record, "coordination", "cross_folder_cochange_ratio")
327 })),
328 6,
329 ),
330 "change_diffusion": round_to(
331 mean(descendants.iter().map(|record| {
332 file_cost(record, "coordination", "change_diffusion")
333 })),
334 6,
335 ),
336 "coordination_pressure": round_to(
337 mean(descendants.iter().map(|record| {
338 file_cost(record, "coordination", "coordination_pressure")
339 })),
340 6,
341 ),
342 },
343 })
344}
345
346fn aggregate_overlay_value(
347 folder_path: &str,
348 overlay_name: &str,
349 descendants: &[&FileAnalysis],
350) -> Value {
351 let overlay_values: Vec<&Value> = descendants
352 .iter()
353 .filter_map(|record| record.overlays.get(overlay_name))
354 .filter(|value| !value.is_null())
355 .collect();
356 if overlay_values.is_empty() {
357 return Value::Null;
358 }
359 let overlay_count = overlay_values.len();
360 let mut numeric: BTreeMap<String, Vec<f64>> = BTreeMap::new();
361 let mut booleans: BTreeMap<String, bool> = BTreeMap::new();
362 let mut lists: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
363 for value in overlay_values {
364 let Some(object) = value.as_object() else {
365 continue;
366 };
367 for (key, value) in object {
368 if key == "path" {
369 continue;
370 }
371 if let Some(boolean) = value.as_bool() {
372 booleans
373 .entry(key.clone())
374 .and_modify(|current| *current |= boolean)
375 .or_insert(boolean);
376 } else if let Some(number) = value.as_f64() {
377 numeric.entry(key.clone()).or_default().push(number);
378 } else if let Some(items) = value.as_array() {
379 let values = lists.entry(key.clone()).or_default();
380 values.extend(
381 items
382 .iter()
383 .filter_map(Value::as_str)
384 .map(ToOwned::to_owned),
385 );
386 }
387 }
388 }
389 let mut result = serde_json::Map::new();
390 result.insert("path".to_string(), json!(folder_path));
391 result.insert("overlay_name".to_string(), json!(overlay_name));
392 for (key, values) in numeric {
393 result.insert(key, json!(round_to(mean(values), 6)));
394 }
395 for (key, value) in booleans {
396 result.insert(key, json!(value));
397 }
398 for (key, values) in lists {
399 result.insert(
400 key,
401 Value::Array(values.into_iter().take(20).map(Value::String).collect()),
402 );
403 }
404 result.insert("descendant_file_count".to_string(), json!(overlay_count));
405 Value::Object(result)
406}
407
408fn folder_overlays(folder_path: &str, descendants: &[&FileAnalysis]) -> Value {
409 let overlay_names = [
410 "organization_health",
411 "verification",
412 "navigation",
413 "blast_radius",
414 "stewardship",
415 "semantic_drift",
416 ];
417 let mut result = serde_json::Map::new();
418 for overlay_name in overlay_names {
419 result.insert(
420 overlay_name.to_string(),
421 aggregate_overlay_value(folder_path, overlay_name, descendants),
422 );
423 }
424 Value::Object(result)
425}
426
427pub fn build_folder_analysis(
428 path: &str,
429 descendants: &[&FileAnalysis],
430 config: &Value,
431) -> Option<FolderAnalysis> {
432 if descendants.is_empty() {
433 return None;
434 }
435 let mut ranked = descendants.to_vec();
436 ranked.sort_by(|left, right| {
437 right
438 .slop_score
439 .total_cmp(&left.slop_score)
440 .then_with(|| right.tokens.cmp(&left.tokens))
441 .then_with(|| left.path.cmp(&right.path))
442 });
443 let top = ranked[0];
444 let total_tokens = descendants.iter().map(|record| record.tokens).sum();
445 let direct: Vec<&FileAnalysis> = descendants
446 .iter()
447 .copied()
448 .filter(|record| direct_parent(&record.path) == path)
449 .collect();
450 let direct_tokens = direct.iter().map(|record| record.tokens).sum();
451 let mut seen_reasons = BTreeSet::new();
452 let mut reason_codes = Vec::new();
453 for record in &ranked {
454 for reason in &record.reason_codes {
455 if seen_reasons.insert(reason.clone()) {
456 reason_codes.push(reason.clone());
457 }
458 }
459 }
460 Some(FolderAnalysis {
461 path: path.to_string(),
462 descendant_file_count: descendants.len(),
463 direct_file_count: direct.len(),
464 bytes: descendants.iter().map(|record| record.bytes).sum(),
465 lines: descendants.iter().map(|record| record.lines).sum(),
466 tokens: total_tokens,
467 direct_tokens,
468 context_band: context_band_for_tokens(total_tokens, config),
469 health_band: folder_health_band(direct_tokens, direct.len(), config),
470 context_pressure: round_to(context_pressure_for_tokens(total_tokens, config), 6),
471 slop_score: top.slop_score,
472 slop_band: top.slop_band.clone(),
473 reason_codes,
474 top_file_path: top.path.clone(),
475 classification: folder_classification(descendants),
476 costs: folder_costs(descendants, total_tokens, config),
477 overlays: folder_overlays(path, descendants),
478 })
479}
480
481pub fn build_folder_analyses(files: &[FileAnalysis], config: &Value) -> Vec<FolderAnalysis> {
482 let mut grouped: BTreeMap<String, Vec<&FileAnalysis>> = BTreeMap::new();
483 for record in files {
484 for folder in parent_folders(&record.path) {
485 grouped.entry(folder).or_default().push(record);
486 }
487 }
488 let mut folders: Vec<FolderAnalysis> = grouped
489 .into_iter()
490 .filter_map(|(path, descendants)| build_folder_analysis(&path, &descendants, config))
491 .collect();
492 folders.sort_by(|left, right| {
493 (left.path != ".")
494 .cmp(&(right.path != "."))
495 .then_with(|| left.path.cmp(&right.path))
496 });
497 folders
498}
499
500pub fn aggregate_folders(files: &[FileAnalysis], config: &Value) -> Vec<FolderAnalysis> {
501 build_folder_analyses(files, config)
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507
508 fn file(path: &str, tokens: usize, revisions: usize, relative_churn: f64) -> FileAnalysis {
509 FileAnalysis {
510 path: path.to_string(),
511 bytes: tokens * 4,
512 lines: tokens / 2,
513 blank_lines: 0,
514 code_lines: tokens / 2,
515 comment_lines: 0,
516 language: "Rust".to_string(),
517 profile: "agent_context".to_string(),
518 classification: "source".to_string(),
519 tokens,
520 context_band: "compact".to_string(),
521 context_pressure: 0.0,
522 content_fingerprint: String::new(),
523 structural_tokens: Vec::new(),
524 structural_token_count: 0,
525 top_structural_terms: Vec::new(),
526 age_days: 0,
527 revisions_window: revisions,
528 recency_weighted_commits: 0.0,
529 added_window: 0,
530 deleted_window: 0,
531 churn_lines_window: 0,
532 line_churn_window: 0,
533 token_churn_window: 0,
534 relative_churn_window: relative_churn,
535 late_churn_spike: 0.0,
536 author_count_window: 0,
537 author_entropy: 0.0,
538 top_author_share: 0.0,
539 days_since_non_bot_edit: None,
540 recent_maintainer_diversity: 0,
541 age_pressure: 0.0,
542 revision_norm: 0.0,
543 relative_churn_norm: 0.0,
544 churn_pressure: 0.0,
545 slop_score: 0.0,
546 slop_band: String::new(),
547 reason_codes: Vec::new(),
548 costs: json!({}),
549 overlays: json!({}),
550 }
551 }
552
553 #[test]
554 fn context_and_slop_thresholds_match_public_contract() {
555 let config = json!({});
556 assert_eq!(context_band_for_tokens(3_072, &config), "compact");
557 assert_eq!(context_band_for_tokens(3_073, &config), "healthy");
558 assert_eq!(context_band_for_tokens(8_001, &config), "warning");
559 assert_eq!(context_band_for_tokens(10_001, &config), "critical");
560 assert_eq!(slop_band_for_score(49.9), "low");
561 assert_eq!(slop_band_for_score(50.0), "moderate");
562 assert_eq!(slop_band_for_score(65.0), "high");
563 assert_eq!(slop_band_for_score(85.0), "critical");
564 }
565
566 #[test]
567 fn scoring_uses_nearest_rank_p95_and_caps_outliers() {
568 let mut files: Vec<FileAnalysis> = (1..=20)
569 .map(|revision| file(&format!("src/{revision}.rs"), 100, revision, 0.0))
570 .collect();
571 apply_scoring(&mut files, &json!({}));
572 assert_eq!(files[17].revision_norm, round_to(18.0 / 19.0, 6));
573 assert_eq!(files[18].revision_norm, 1.0);
574 assert_eq!(files[19].revision_norm, 1.0);
575 assert_eq!(files[19].slop_score, 12.0);
576 }
577
578 #[test]
579 fn scoring_preserves_reason_order_and_rounded_band() {
580 let mut record = file("src/legacy.rs", 12_000, 10, 2.0);
581 record.context_band = "critical".to_string();
582 record.context_pressure = 1.0;
583 record.age_days = 180;
584 apply_scoring(std::slice::from_mut(&mut record), &json!({}));
585 assert_eq!(record.slop_score, 90.0);
586 assert_eq!(record.slop_band, "critical");
587 assert_eq!(
588 record.reason_codes,
589 vec![
590 "critical_token_cost",
591 "old_file",
592 "high_revision_frequency",
593 "high_relative_churn",
594 "old_and_volatile",
595 ]
596 );
597 }
598
599 #[test]
600 fn stable_relative_line_churn_drives_score_and_action_reason() {
601 let mut files = vec![
602 file("src/quiet.rs", 100, 1, 0.1),
603 file("src/volatile.rs", 100, 1, 2.0),
604 ];
605 apply_scoring(&mut files, &json!({}));
606
607 assert_eq!(files[0].relative_churn_norm, 0.05);
608 assert_eq!(files[1].relative_churn_norm, 1.0);
609 assert!(files[1].slop_score > files[0].slop_score);
610 assert!(
611 !files[0]
612 .reason_codes
613 .contains(&"high_relative_churn".to_string())
614 );
615 assert!(
616 files[1]
617 .reason_codes
618 .contains(&"high_relative_churn".to_string())
619 );
620 }
621
622 #[test]
623 fn stable_history_fields_keep_schema_four_integer_shapes() {
624 let mut record = file("src/history.rs", 100, 2, 0.5);
625 record.added_window = 7;
626 record.deleted_window = 3;
627 record.churn_lines_window = 10;
628 record.line_churn_window = 10;
629 record.token_churn_window = 24;
630
631 let value = serde_json::to_value(record).unwrap();
632 assert_eq!(value["added_window"].as_u64(), Some(7));
633 assert_eq!(value["deleted_window"].as_u64(), Some(3));
634 assert_eq!(value["churn_lines_window"].as_u64(), Some(10));
635 assert_eq!(value["token_churn_window"].as_u64(), Some(24));
636 assert_eq!(value["relative_churn_window"].as_f64(), Some(0.5));
637 }
638
639 #[test]
640 fn folder_aggregation_tracks_descendant_and_direct_pressure_separately() {
641 let mut top = file("src/top.rs", 20_000, 1, 0.0);
642 top.slop_score = 60.0;
643 top.slop_band = "moderate".to_string();
644 top.reason_codes = vec!["high_token_cost".to_string()];
645 let mut nested = file("src/nested/child.rs", 20_000, 1, 0.0);
646 nested.slop_score = 70.0;
647 nested.slop_band = "high".to_string();
648 nested.reason_codes = vec!["high_relative_churn".to_string()];
649 let folders = build_folder_analyses(&[top, nested], &json!({}));
650 let root = folders.iter().find(|folder| folder.path == ".").unwrap();
651 let src = folders.iter().find(|folder| folder.path == "src").unwrap();
652 let nested = folders
653 .iter()
654 .find(|folder| folder.path == "src/nested")
655 .unwrap();
656 assert_eq!(root.direct_file_count, 0);
657 assert_eq!(root.descendant_file_count, 2);
658 assert_eq!(src.direct_file_count, 1);
659 assert_eq!(src.direct_tokens, 20_000);
660 assert_eq!(src.tokens, 40_000);
661 assert_eq!(src.top_file_path, "src/nested/child.rs");
662 assert_eq!(src.reason_codes, ["high_relative_churn", "high_token_cost"]);
663 assert_eq!(nested.direct_file_count, 1);
664 }
665
666 #[test]
667 fn folder_health_uses_direct_token_and_file_limits() {
668 let config = json!({});
669 assert_eq!(folder_health_band(31_999, 17, &config), "compact");
670 assert_eq!(folder_health_band(32_000, 17, &config), "healthy");
671 assert_eq!(folder_health_band(128_001, 17, &config), "warning");
672 assert_eq!(folder_health_band(1, 18, &config), "warning");
673 assert_eq!(folder_health_band(256_001, 1, &config), "refactor_required");
674 assert_eq!(folder_health_band(1, 38, &config), "refactor_required");
675 }
676}