Skip to main content

claude_native/rules/project_specific/
ml.rs

1use 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
9// ── Rule DS1: Notebooks are not primary source ──────────────────────
10
11pub 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
49// ── Rule DS2: Model files ignored ───────────────────────────────────
50
51pub 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
90// ── Rule DS3: Dataset files ignored ─────────────────────────────────
91
92pub 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
135// ── Rule DS4: Experiment workflow documented ─────────────────────────
136
137pub 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
174// ── Rule DS5: Requirements pin exact versions ───────────────────────
175
176pub 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}