1use anyhow::Result;
20use fx_durable_ga::{
21 bootstrap,
22 models::{
23 Crossover, Distribution, Encodeable, Evaluator, FitnessGoal, GeneBounds, Mutagen,
24 MutationRate, Request, Schedule, Selector, Temperature, Terminated,
25 },
26 register_event_handlers, register_job_handlers,
27};
28use fx_mq_jobs::FX_MQ_JOBS_SCHEMA_NAME;
29use fx_mq_jobs::Queries;
30use serde::Deserialize;
31use sqlx::postgres::PgPoolOptions;
32use std::time::Duration;
33use std::{env, sync::Arc};
34use tracing::Level;
35use uuid::Uuid;
36
37const WORKERS: usize = 5;
38const FITNESS_TARGET: f64 = 1.0;
39
40const SOURCE_COLUMNS: &[&str] = &[
42 "TEMP", "PRES", "DEWP", "RAIN", "WSPM", "PM2.5", "PM10", "SO2", "NO2", "CO", "O3",
43];
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47enum Transform {
48 ZScore10,
49 ZScore24,
50 ZScore48,
51 ZScore96,
52 Roc1,
53 Roc4,
54 Roc8,
55 Roc12,
56 Std10,
57 Std24,
58 Std48,
59 Std96,
60}
61
62impl Transform {
63 fn from_gene(gene: i64) -> Option<Self> {
64 match gene {
65 0 => Some(Self::ZScore10),
66 1 => Some(Self::ZScore24),
67 2 => Some(Self::ZScore48),
68 3 => Some(Self::ZScore96),
69 4 => Some(Self::Roc1),
70 5 => Some(Self::Roc4),
71 6 => Some(Self::Roc8),
72 7 => Some(Self::Roc12),
73 8 => Some(Self::Std10),
74 9 => Some(Self::Std24),
75 10 => Some(Self::Std48),
76 11 => Some(Self::Std96),
77 _ => None,
78 }
79 }
80
81 fn to_gene(self) -> i64 {
82 match self {
83 Self::ZScore10 => 0,
84 Self::ZScore24 => 1,
85 Self::ZScore48 => 2,
86 Self::ZScore96 => 3,
87 Self::Roc1 => 4,
88 Self::Roc4 => 5,
89 Self::Roc8 => 6,
90 Self::Roc12 => 7,
91 Self::Std10 => 8,
92 Self::Std24 => 9,
93 Self::Std48 => 10,
94 Self::Std96 => 11,
95 }
96 }
97
98 fn to_string(self) -> String {
99 match self {
100 Self::ZScore10 => "ZSCORE(10)".to_string(),
101 Self::ZScore24 => "ZSCORE(24)".to_string(),
102 Self::ZScore48 => "ZSCORE(48)".to_string(),
103 Self::ZScore96 => "ZSCORE(96)".to_string(),
104 Self::Roc1 => "ROC(1)".to_string(),
105 Self::Roc4 => "ROC(4)".to_string(),
106 Self::Roc8 => "ROC(8)".to_string(),
107 Self::Roc12 => "ROC(12)".to_string(),
108 Self::Std10 => "STD(10)".to_string(),
109 Self::Std24 => "STD(24)".to_string(),
110 Self::Std48 => "STD(48)".to_string(),
111 Self::Std96 => "STD(96)".to_string(),
112 }
113 }
114}
115
116#[derive(Debug, Clone)]
118struct Feature {
119 source: String,
120 transforms: Vec<Transform>,
121}
122
123impl Feature {
124 fn to_cli_arg(&self, name: &str) -> String {
125 let pipeline = self
126 .transforms
127 .iter()
128 .map(|t| t.to_string())
129 .collect::<Vec<_>>()
130 .join(" ");
131
132 if pipeline.is_empty() {
133 format!("{}={}", name, self.source)
134 } else {
135 format!("{}={}:{}", name, self.source, pipeline)
136 }
137 }
138}
139
140#[derive(Debug, Clone)]
142struct FeatureConfig {
143 features: Vec<Feature>,
144 hidden_size: usize,
145 learning_rate: f64,
146 sequence_length: usize,
147}
148
149impl Encodeable for FeatureConfig {
150 const NAME: &'static str = "feature_engineering";
151
152 type Phenotype = FeatureConfig;
153
154 fn morphology() -> Vec<GeneBounds> {
155 let mut bounds = Vec::new();
156
157 for _ in 0..7 {
163 bounds.push(GeneBounds::integer(0, 10, 11).unwrap()); bounds.push(GeneBounds::integer(0, 2, 3).unwrap()); bounds.push(GeneBounds::integer(0, 11, 12).unwrap()); bounds.push(GeneBounds::integer(0, 11, 12).unwrap()); }
168
169 bounds.push(GeneBounds::integer(0, 5, 6).unwrap()); bounds.push(GeneBounds::integer(0, 2, 3).unwrap()); bounds.push(GeneBounds::integer(0, 9, 10).unwrap()); bounds
175 }
176
177 fn encode(&self) -> Vec<i64> {
178 let mut genes = Vec::new();
179
180 for feature in &self.features {
182 let source_idx = SOURCE_COLUMNS
184 .iter()
185 .position(|&col| col == feature.source)
186 .unwrap_or(0) as i64;
187 genes.push(source_idx);
188
189 genes.push(feature.transforms.len().min(2) as i64);
191
192 for i in 0..2 {
194 if i < feature.transforms.len() {
195 genes.push(feature.transforms[i].to_gene());
196 } else {
197 genes.push(0);
198 }
199 }
200 }
201
202 let hidden_size_idx = match self.hidden_size {
204 4 => 0,
205 8 => 1,
206 16 => 2,
207 32 => 3,
208 64 => 4,
209 128 => 5,
210 _ => 2, };
212 genes.push(hidden_size_idx);
213
214 let lr_idx = if self.learning_rate <= 1e-4 {
215 0
216 } else if self.learning_rate <= 5e-4 {
217 1
218 } else {
219 2
220 };
221 genes.push(lr_idx);
222
223 let seq_len_idx = ((self.sequence_length / 10).saturating_sub(1)).min(9) as i64;
224 genes.push(seq_len_idx);
225
226 genes
227 }
228
229 fn decode(genes: &[i64]) -> Self::Phenotype {
230 let mut features = Vec::new();
231
232 for i in 0..7 {
234 let base_idx = i * 4;
235
236 let source_idx = genes[base_idx].clamp(0, 10) as usize;
237 let source = SOURCE_COLUMNS[source_idx].to_string();
238
239 let pipeline_length = genes[base_idx + 1].clamp(0, 2) as usize;
240
241 let mut transforms = Vec::new();
242 for j in 0..pipeline_length {
243 if let Some(transform) = Transform::from_gene(genes[base_idx + 2 + j]) {
244 transforms.push(transform);
245 }
246 }
247
248 features.push(Feature { source, transforms });
249 }
250
251 let hidden_size = match genes[28] {
253 0 => 4,
254 1 => 8,
255 2 => 16,
256 3 => 32,
257 4 => 64,
258 5 => 128,
259 _ => 32,
260 };
261
262 let learning_rate = match genes[29] {
263 0 => 1e-4,
264 1 => 5e-4,
265 2 => 1e-3,
266 _ => 5e-4,
267 };
268
269 let sequence_length = ((genes[30].clamp(0, 9) + 1) * 10) as usize;
270
271 FeatureConfig {
272 features,
273 hidden_size,
274 learning_rate,
275 sequence_length,
276 }
277 }
278}
279
280struct FeatureEvaluator;
281
282#[derive(Deserialize)]
283struct ResultOutput {
284 validation_loss: f64,
285}
286
287impl Evaluator<FeatureConfig> for FeatureEvaluator {
288 fn fitness<'a>(
289 &self,
290 genotype_id: Uuid,
291 phenotype: FeatureConfig,
292 _: &Request,
293 _: &'a Box<dyn Terminated>,
294 ) -> futures::future::BoxFuture<'a, Result<f64, anyhow::Error>> {
295 Box::pin(async move {
296 let model_save_path = format!("./model_storage/{}", genotype_id);
297 let mut args = vec![
298 "train".to_string(),
299 "--hidden-size".to_string(),
300 phenotype.hidden_size.to_string(),
301 "--learning-rate".to_string(),
302 phenotype.learning_rate.to_string(),
303 "--sequence-length".to_string(),
304 phenotype.sequence_length.to_string(),
305 "--prediction-horizon".to_string(),
306 "1".to_string(),
307 "--batch-size".to_string(),
308 "100".to_string(),
309 "--epochs".to_string(),
310 "25".to_string(),
311 "--model-save-path".to_string(),
312 model_save_path,
313 ];
314
315 args.push("--feature".to_string());
317 args.push("hour_sin=hour:SIN(24)".to_string());
318 args.push("--feature".to_string());
319 args.push("hour_cos=hour:COS(24)".to_string());
320 args.push("--feature".to_string());
321 args.push("month_sin=month:SIN(12)".to_string());
322 args.push("--feature".to_string());
323 args.push("month_cos=month:COS(12)".to_string());
324
325 for (i, feature) in phenotype.features.iter().enumerate() {
327 let feature_name = format!("feat_{}", i);
328 args.push("--feature".to_string());
329 args.push(feature.to_cli_arg(&feature_name));
330 }
331
332 args.push("--target".to_string());
334 args.push("target_temp=TEMP".to_string());
335
336 let output = tokio::process::Command::new("feng")
338 .args(&args)
339 .output()
340 .await
341 .expect("Failed to run feng");
342
343 let stderr = String::from_utf8_lossy(&output.stderr);
345 if !stderr.is_empty() {
346 eprintln!("feng stderr: {}", stderr);
347 }
348
349 if !output.status.success() {
351 let stdout = String::from_utf8_lossy(&output.stdout);
352 return Err(anyhow::anyhow!(
353 "feng failed with exit code {:?}. stdout: {}, stderr: {}",
354 output.status.code(),
355 stdout,
356 stderr
357 ));
358 }
359
360 let stdout = String::from_utf8_lossy(&output.stdout);
362 let last_line = stdout.lines().last().unwrap_or("");
363 let result: ResultOutput = serde_json::from_str(last_line).map_err(|e| {
364 anyhow::anyhow!(
365 "Failed to parse JSON from feng: {}. Last line was: {}",
366 e,
367 last_line
368 )
369 })?;
370
371 Ok(result.validation_loss)
372 })
373 }
374}
375
376#[tokio::main]
377async fn main() -> Result<()> {
378 dotenv::from_filename(".env.local").ok();
379 tracing_subscriber::fmt()
380 .pretty()
381 .with_thread_ids(true)
382 .with_max_level(Level::INFO)
383 .init();
384
385 let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
386 let pool = PgPoolOptions::new()
387 .max_connections(10)
388 .connect(&database_url)
389 .await?;
390
391 fx_durable_ga::migrations::run_default_migrations(&pool).await?;
393
394 let service = Arc::new(
395 bootstrap(pool.clone())
396 .await?
397 .register::<FeatureConfig, _>(FeatureEvaluator)
398 .await?
399 .build(),
400 );
401
402 let mut registry = fx_event_bus::EventHandlerRegistry::new();
403 register_event_handlers(
404 Arc::new(Queries::new(FX_MQ_JOBS_SCHEMA_NAME)),
405 service.clone(),
406 &mut registry,
407 );
408 let mut listener = fx_event_bus::Listener::new(pool.clone(), registry);
409 tokio::spawn(async move { listener.listen(None).await });
410
411 let host_id = Uuid::parse_str("00000000-0000-0000-0000-123456789abc")?;
412 let mut jobs_listener = fx_mq_jobs::Listener::new(
413 pool.clone(),
414 register_job_handlers(&service, fx_mq_jobs::RegistryBuilder::new()),
415 WORKERS,
416 host_id,
417 Duration::from_secs(600),
418 )
419 .await?;
420 tokio::spawn(async move { jobs_listener.listen().await });
421
422 let request_id = service
423 .new_optimization_request(
424 FeatureConfig::NAME,
425 FeatureConfig::HASH,
426 FitnessGoal::minimize(FITNESS_TARGET)?,
427 Schedule::generational(40, 10),
428 Selector::tournament(5, 45)?,
429 Mutagen::new(Temperature::constant(0.7)?, MutationRate::constant(0.35)?),
430 Crossover::uniform(0.5)?,
431 Distribution::latin_hypercube(40),
432 None::<()>,
433 )
434 .await?;
435
436 let timeout = Duration::from_secs(7200);
438 let poll_interval = Duration::from_secs(15);
439 let start = std::time::Instant::now();
440
441 loop {
442 if service.is_request_concluded(request_id).await? {
443 println!("\nOptimization completed!");
444 break;
445 }
446
447 if start.elapsed() > timeout {
448 println!("\nOptimization timed out after 1 hour.");
449 break;
450 }
451
452 tokio::time::sleep(poll_interval).await;
453 }
454
455 if let Some((genotype, fitness)) = service.get_best_genotype(request_id).await? {
457 let config = FeatureConfig::decode(&genotype.genome());
458 println!("\n=== Best Configuration ===");
459 println!("Fitness (MSE): {:.6}", fitness);
460 println!("RMSE: {:.6}°C", fitness.sqrt());
461 println!("\nHyperparameters:");
462 println!(" Hidden Size: {}", config.hidden_size);
463 println!(" Learning Rate: {}", config.learning_rate);
464 println!(" Sequence Length: {}", config.sequence_length);
465 println!("\nFeatures:");
466 for (i, feature) in config.features.iter().enumerate() {
467 let pipeline = feature
468 .transforms
469 .iter()
470 .map(|t| t.to_string())
471 .collect::<Vec<_>>()
472 .join(" → ");
473 if pipeline.is_empty() {
474 println!(" feat_{}: {}", i, feature.source);
475 } else {
476 println!(" feat_{}: {} → {}", i, feature.source, pipeline);
477 }
478 }
479 } else {
480 println!("No genotypes were evaluated.");
481 }
482
483 Ok(())
484}