claude_native/rules/project_specific/
ml.rs1use crate::detection::{PrimaryType, ProjectType};
2use crate::rules::*;
3use crate::scan::ProjectContext;
4
5fn is_ml(pt: &ProjectType) -> bool {
6 matches!(pt.primary, PrimaryType::ML)
7}
8
9pub struct NotebooksNotPrimary;
12
13impl Rule for NotebooksNotPrimary {
14 fn id(&self) -> &str { "DS1" }
15 fn name(&self) -> &str { "Core logic in .py files, not notebooks" }
16 fn dimension(&self) -> Dimension { Dimension::ContextEfficiency }
17 fn severity(&self) -> Severity { Severity::High }
18
19 fn applies_to(&self, pt: &ProjectType) -> bool { is_ml(pt) }
20
21 fn check(&self, ctx: &ProjectContext) -> RuleResult {
22 let notebook_count = ctx.all_files.iter().filter(|f| {
23 f.path.extension().map(|e| e == "ipynb").unwrap_or(false)
24 }).count();
25 let py_count = ctx.all_files.iter().filter(|f| {
26 f.path.extension().map(|e| e == "py").unwrap_or(false) && !f.is_test
27 }).count();
28
29 if notebook_count == 0 {
30 return self.pass();
31 }
32
33 if py_count >= notebook_count {
34 self.pass()
35 } else {
36 self.fail(
37 &format!("{notebook_count} notebooks vs {py_count} .py files — notebooks are too dominant"),
38 Suggestion {
39 priority: SuggestionPriority::HighImpact,
40 title: "Extract core logic from notebooks to .py".into(),
41 description: "Notebooks with outputs cost 10-50x more tokens than equivalent .py files. Extract training loops, data processing, and model definitions into .py files.".into(),
42 effort: Effort::HalfDay,
43 },
44 )
45 }
46 }
47}
48
49pub struct ModelFilesIgnored;
52
53impl Rule for ModelFilesIgnored {
54 fn id(&self) -> &str { "DS2" }
55 fn name(&self) -> &str { "Model weight files are ignored" }
56 fn dimension(&self) -> Dimension { Dimension::ContextEfficiency }
57 fn severity(&self) -> Severity { Severity::Critical }
58
59 fn applies_to(&self, pt: &ProjectType) -> bool { is_ml(pt) }
60
61 fn check(&self, ctx: &ProjectContext) -> RuleResult {
62 let model_exts = ["pkl", "h5", "pth", "onnx", "safetensors", "bin", "pt"];
63 let has_models = ctx.all_files.iter().any(|f| {
64 f.path.extension().and_then(|e| e.to_str())
65 .map(|e| model_exts.contains(&e))
66 .unwrap_or(false)
67 });
68
69 if !has_models {
70 return self.pass();
71 }
72
73 let ignored = model_exts.iter().any(|e| ctx.claudeignore_contains(e));
74 if ignored {
75 self.pass()
76 } else {
77 self.fail(
78 "Model weight files found but not in .claudeignore",
79 Suggestion {
80 priority: SuggestionPriority::QuickWin,
81 title: "Ignore model files".into(),
82 description: "Add *.pkl, *.h5, *.pth, *.onnx, *.safetensors, *.bin to .claudeignore. Model files are binary (100MB-10GB) — Claude can't read them.".into(),
83 effort: Effort::Minutes,
84 },
85 )
86 }
87 }
88}
89
90pub struct DatasetFilesIgnored;
93
94impl Rule for DatasetFilesIgnored {
95 fn id(&self) -> &str { "DS3" }
96 fn name(&self) -> &str { "Dataset files are ignored" }
97 fn dimension(&self) -> Dimension { Dimension::ContextEfficiency }
98 fn severity(&self) -> Severity { Severity::Critical }
99
100 fn applies_to(&self, pt: &ProjectType) -> bool { is_ml(pt) }
101
102 fn check(&self, ctx: &ProjectContext) -> RuleResult {
103 let data_dir = ctx.root.join("data");
104 let has_data = data_dir.is_dir() || ctx.all_files.iter().any(|f| {
105 f.path.extension().and_then(|e| e.to_str())
106 .map(|e| matches!(e, "csv" | "parquet" | "feather" | "arrow"))
107 .unwrap_or(false)
108 && f.size_bytes > 1_000_000
109 });
110
111 if !has_data {
112 return self.pass();
113 }
114
115 let ignored = ctx.claudeignore_contains("data/")
116 || ctx.claudeignore_contains("*.csv")
117 || ctx.claudeignore_contains("*.parquet");
118
119 if ignored {
120 self.pass()
121 } else {
122 self.fail(
123 "Dataset files/directories found but not in .claudeignore",
124 Suggestion {
125 priority: SuggestionPriority::QuickWin,
126 title: "Ignore dataset files".into(),
127 description: "Add data/, *.csv, *.parquet, *.feather to .claudeignore. Claude should sample data via `head -20 data.csv`, not read entire files.".into(),
128 effort: Effort::Minutes,
129 },
130 )
131 }
132 }
133}
134
135pub struct ExperimentWorkflowDocumented;
138
139impl Rule for ExperimentWorkflowDocumented {
140 fn id(&self) -> &str { "DS4" }
141 fn name(&self) -> &str { "Experiment workflow documented" }
142 fn dimension(&self) -> Dimension { Dimension::Foundation }
143 fn severity(&self) -> Severity { Severity::Medium }
144
145 fn applies_to(&self, pt: &ProjectType) -> bool { is_ml(pt) }
146
147 fn check(&self, ctx: &ProjectContext) -> RuleResult {
148 let content = match &ctx.claude_md_content {
149 Some(c) => c.to_lowercase(),
150 None => return self.skip(),
151 };
152
153 let has_workflow = content.contains("train")
154 || content.contains("evaluat")
155 || content.contains("experiment")
156 || content.contains("metric");
157
158 if has_workflow {
159 self.pass()
160 } else {
161 self.warn(
162 "CLAUDE.md doesn't document the ML experiment workflow",
163 Suggestion {
164 priority: SuggestionPriority::NiceToHave,
165 title: "Document experiment workflow".into(),
166 description: "Add to CLAUDE.md: how to run training, evaluate, what metrics matter, where results are stored. Without this, Claude runs the wrong step.".into(),
167 effort: Effort::Minutes,
168 },
169 )
170 }
171 }
172}
173
174pub struct RequirementsPinned;
177
178impl Rule for RequirementsPinned {
179 fn id(&self) -> &str { "DS5" }
180 fn name(&self) -> &str { "Requirements pin exact versions" }
181 fn dimension(&self) -> Dimension { Dimension::CodeQuality }
182 fn severity(&self) -> Severity { Severity::Medium }
183
184 fn applies_to(&self, pt: &ProjectType) -> bool { is_ml(pt) }
185
186 fn check(&self, ctx: &ProjectContext) -> RuleResult {
187 let content = match ctx.read_root_file("requirements.txt") {
188 Some(c) => c,
189 None => return self.pass(),
190 };
191
192 let total_deps = content.lines()
193 .filter(|l| !l.trim().is_empty() && !l.starts_with('#') && !l.starts_with('-'))
194 .count();
195 let pinned = content.lines()
196 .filter(|l| l.contains("=="))
197 .count();
198
199 if total_deps == 0 {
200 return self.pass();
201 }
202
203 let ratio = pinned as f64 / total_deps as f64;
204 if ratio >= 0.8 {
205 self.pass()
206 } else {
207 self.warn(
208 &format!("Only {:.0}% of dependencies use exact versions (==)", ratio * 100.0),
209 Suggestion {
210 priority: SuggestionPriority::NiceToHave,
211 title: "Pin ML dependency versions".into(),
212 description: "ML deps have complex compatibility (CUDA, framework versions). Use == pinning: `torch==2.1.0` not `torch>=2.0`. Prevents 'works on my machine' failures.".into(),
213 effort: Effort::Minutes,
214 },
215 )
216 }
217 }
218}