1use serde::{Deserialize, Serialize};
13
14use crate::energy::EnergyModel;
15use crate::residual::{CorrectionDirection, ResidualClass, ResidualEvent};
16
17#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
19pub struct DomainId(pub String);
20
21impl DomainId {
22 pub fn new(id: impl Into<String>) -> Self {
23 Self(id.into())
24 }
25}
26
27impl std::fmt::Display for DomainId {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 f.write_str(&self.0)
30 }
31}
32
33#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
37pub struct WorkspaceSnapshot {
38 pub root: String,
39 pub files: Vec<String>,
41}
42
43impl WorkspaceSnapshot {
44 pub fn new(root: impl Into<String>, files: Vec<String>) -> Self {
45 Self {
46 root: root.into(),
47 files,
48 }
49 }
50
51 pub fn has_file_named(&self, name: &str) -> bool {
53 self.files
54 .iter()
55 .any(|f| f == name || f.ends_with(&format!("/{name}")))
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct DomainDetection {
62 pub domain: DomainId,
63 pub activated: bool,
64 pub confidence: f64,
66 pub evidence: Vec<String>,
67}
68
69#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
71pub struct DomainScope {
72 pub label: String,
73 pub paths: Vec<String>,
74}
75
76#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
80pub struct ResidualSchema {
81 pub classes: Vec<ResidualClass>,
82}
83
84impl ResidualSchema {
85 pub fn new(classes: Vec<ResidualClass>) -> Self {
86 Self { classes }
87 }
88
89 pub fn allows(&self, class: ResidualClass) -> bool {
90 self.classes.contains(&class)
91 }
92}
93
94pub trait AgentDomainPackage: Send + Sync {
100 fn domain_id(&self) -> DomainId;
102
103 fn detect(&self, workspace: &WorkspaceSnapshot) -> DomainDetection;
105
106 fn residual_schema(&self, scope: &DomainScope) -> ResidualSchema;
108
109 fn energy_model(&self, scope: &DomainScope) -> EnergyModel;
111
112 fn correction_directions(&self, residuals: &[ResidualEvent]) -> Vec<CorrectionDirection>;
116}
117
118#[derive(Default)]
123pub struct DomainRegistry {
124 packages: Vec<Box<dyn AgentDomainPackage>>,
125}
126
127impl std::fmt::Debug for DomainRegistry {
128 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129 f.debug_struct("DomainRegistry")
130 .field(
131 "domains",
132 &self
133 .packages
134 .iter()
135 .map(|p| p.domain_id())
136 .collect::<Vec<_>>(),
137 )
138 .finish()
139 }
140}
141
142impl DomainRegistry {
143 pub fn new() -> Self {
144 Self::default()
145 }
146
147 pub fn register(&mut self, package: Box<dyn AgentDomainPackage>) {
150 self.packages.push(package);
151 }
152
153 pub fn len(&self) -> usize {
154 self.packages.len()
155 }
156
157 pub fn is_empty(&self) -> bool {
158 self.packages.is_empty()
159 }
160
161 pub fn domain_ids(&self) -> Vec<DomainId> {
163 self.packages.iter().map(|p| p.domain_id()).collect()
164 }
165
166 pub fn by_id(&self, id: &DomainId) -> Option<&dyn AgentDomainPackage> {
168 self.packages
169 .iter()
170 .find(|p| &p.domain_id() == id)
171 .map(|p| p.as_ref())
172 }
173
174 pub fn detect_best(&self, workspace: &WorkspaceSnapshot) -> Option<&dyn AgentDomainPackage> {
176 self.packages
177 .iter()
178 .map(|p| (p, p.detect(workspace)))
179 .filter(|(_, d)| d.activated)
180 .max_by(|(_, a), (_, b)| {
181 a.confidence
182 .partial_cmp(&b.confidence)
183 .unwrap_or(std::cmp::Ordering::Equal)
184 })
185 .map(|(p, _)| p.as_ref())
186 }
187
188 pub fn select(
190 &self,
191 explicit: Option<&DomainId>,
192 workspace: &WorkspaceSnapshot,
193 ) -> Option<&dyn AgentDomainPackage> {
194 match explicit {
195 Some(id) => self.by_id(id),
196 None => self.detect_best(workspace),
197 }
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use crate::energy::EnergyModel;
205
206 struct StubDomain {
207 id: &'static str,
208 marker: &'static str,
209 confidence: f64,
210 }
211
212 impl AgentDomainPackage for StubDomain {
213 fn domain_id(&self) -> DomainId {
214 DomainId::new(self.id)
215 }
216 fn detect(&self, ws: &WorkspaceSnapshot) -> DomainDetection {
217 let activated = ws.has_file_named(self.marker);
218 DomainDetection {
219 domain: self.domain_id(),
220 activated,
221 confidence: if activated { self.confidence } else { 0.0 },
222 evidence: vec![],
223 }
224 }
225 fn residual_schema(&self, _: &DomainScope) -> ResidualSchema {
226 ResidualSchema::new(vec![])
227 }
228 fn energy_model(&self, _: &DomainScope) -> EnergyModel {
229 EnergyModel::new(self.id, 0.5)
230 }
231 fn correction_directions(&self, _: &[ResidualEvent]) -> Vec<CorrectionDirection> {
232 vec![]
233 }
234 }
235
236 fn registry() -> DomainRegistry {
237 let mut r = DomainRegistry::new();
238 r.register(Box::new(StubDomain {
239 id: "coding",
240 marker: "Cargo.toml",
241 confidence: 0.9,
242 }));
243 r.register(Box::new(StubDomain {
244 id: "research",
245 marker: "refs.bib",
246 confidence: 0.8,
247 }));
248 r
249 }
250
251 #[test]
252 fn explicit_selection_wins() {
253 let r = registry();
254 let ws = WorkspaceSnapshot::new("/r", vec!["Cargo.toml".into(), "refs.bib".into()]);
255 let chosen = r.select(Some(&DomainId::new("research")), &ws).unwrap();
256 assert_eq!(chosen.domain_id(), DomainId::new("research"));
257 }
258
259 #[test]
260 fn detection_selects_best_when_no_explicit() {
261 let r = registry();
262 let ws = WorkspaceSnapshot::new("/r", vec!["refs.bib".into()]);
263 let chosen = r.select(None, &ws).unwrap();
264 assert_eq!(chosen.domain_id(), DomainId::new("research"));
265 }
266
267 #[test]
268 fn registry_admits_multiple_domains() {
269 let r = registry();
270 assert_eq!(r.len(), 2);
271 assert!(r.by_id(&DomainId::new("coding")).is_some());
272 assert!(r.by_id(&DomainId::new("missing")).is_none());
273 }
274}