1use std::{
2 fs::{self, File, OpenOptions},
3 io::{BufRead, BufReader, Write},
4 path::{Path, PathBuf},
5 sync::atomic::{AtomicU64, Ordering},
6};
7
8use serde::{Deserialize, Serialize};
9use thiserror::Error;
10
11use crate::{
12 RejectionReason, SearchSpec, StructuralDescriptor, StructuralScoreWeights,
13 canonical::digest,
14 transform::{Provenance, TransformKind},
15};
16
17pub const CANDIDATE_SCHEMA: u32 = 4;
18pub const MANIFEST_SCHEMA: u32 = 8;
19static TEMPORARY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
20
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct CandidateRecord {
24 pub schema: u32,
25 #[serde(default)]
26 pub run_id: String,
27 pub expression: String,
28 pub fingerprint: String,
29 pub semantic_fingerprint: String,
30 pub structural_score: f64,
31 #[serde(default)]
32 pub score_schema: u32,
33 #[serde(default)]
34 pub score_components: StructuralScoreComponents,
35 #[serde(default)]
36 pub normalized_weights: StructuralScoreWeights,
37 #[serde(default)]
38 pub structural_descriptor: StructuralDescriptor,
39 pub root_family: String,
40 pub nodes: usize,
41 pub depth: usize,
42 pub provenance: Provenance,
43}
44
45#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
46pub struct StructuralScoreComponents {
47 pub simplicity: f64,
48 pub archive_novelty: f64,
49 pub family_diversity: f64,
50 pub lineage_diversity: f64,
51 pub structural_novelty: f64,
52 pub transform_diversity: f64,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct FeedbackProvenance {
58 pub dataset_id: String,
59 pub context_checksum: String,
60 pub feedback_checksum: String,
61 pub configuration_checksum: String,
62 pub outcome_label: String,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct OperatorPolicyProvenance {
68 pub configuration_checksum: String,
69 pub base_catalog_checksum: String,
70 pub adjusted_catalog_checksum: String,
71 pub generation_weights: std::collections::BTreeMap<String, u16>,
72 pub transform_action_weights: std::collections::BTreeMap<String, u16>,
73 pub transform_action_source: String,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub transform_action_archive_checksum: Option<String>,
76}
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct RunManifest {
80 pub schema: u32,
81 pub run_id: String,
82 pub candidate_schema: u32,
83 pub operator_catalog_checksum: String,
84 pub tool_version: String,
85 pub search_spec: SearchSpec,
86 pub config_checksum: String,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub measured_feedback: Option<FeedbackProvenance>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub operator_policy: Option<OperatorPolicyProvenance>,
91 #[serde(default)]
92 pub guided_population_candidates: u64,
93 #[serde(default)]
94 pub seeded_from_archive: bool,
95 #[serde(default)]
96 pub frozen_seed_parents: bool,
97 #[serde(default, skip_serializing_if = "usize_is_zero")]
98 pub feedback_elite_parent_count: usize,
99 #[serde(default)]
100 pub catalog_scaffold_policy: crate::CatalogScaffoldPolicy,
101 pub archive_records: u64,
102 pub archive_accepted_records: u64,
103 pub archive_duplicate_records: u64,
104 pub archive_rejected_records: u64,
105 pub archive_rejection_reasons: std::collections::BTreeMap<RejectionReason, u64>,
106 pub attempted_candidates: u64,
107 pub rejected_invalid_candidates: u64,
108 pub valid_candidates: u64,
109 pub rejected_trivial_candidates: u64,
110 pub rejection_reasons: std::collections::BTreeMap<RejectionReason, u64>,
111 pub duplicate_candidates: u64,
112 pub retained_candidates: u64,
113 pub accepted_transform_counts: std::collections::BTreeMap<TransformKind, u64>,
114 pub retained_transform_counts: std::collections::BTreeMap<TransformKind, u64>,
115 pub generations_completed: u64,
116 pub population_capacity: usize,
117 pub peak_population: usize,
118 pub elite_archive_capacity: usize,
119 pub peak_elite_archive: usize,
120 pub semantic_archive_capacity: usize,
121 pub peak_semantic_archive: usize,
122 pub descriptor_archive_capacity: usize,
123 pub peak_descriptor_archive: usize,
124 pub elapsed_milliseconds: u128,
125 pub termination_reason: String,
126 pub candidate_content_checksum: String,
127}
128
129#[allow(clippy::trivially_copy_pass_by_ref)]
130fn usize_is_zero(value: &usize) -> bool {
131 *value == 0
132}
133
134pub const CHECKPOINT_SCHEMA: u32 = 2;
135
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
137pub struct CheckpointDraft {
138 pub expression: String,
139 pub provenance: Provenance,
140 pub semantic_fingerprint: String,
141 pub descriptor: StructuralDescriptor,
142}
143
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
145pub struct RunCheckpoint {
146 pub schema: u32,
147 pub run_id: String,
148 pub config_checksum: String,
149 pub operator_catalog_checksum: String,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub measured_feedback: Option<FeedbackProvenance>,
152 pub next_generation: u64,
153 pub generations_completed: u64,
154 pub attempted_candidates: u64,
155 pub rejected_invalid_candidates: u64,
156 pub duplicate_candidates: u64,
157 pub valid_candidates: u64,
158 pub rejection_reasons: std::collections::BTreeMap<RejectionReason, u64>,
159 pub accepted_transform_counts: std::collections::BTreeMap<TransformKind, u64>,
160 pub semantic_archive: Vec<String>,
161 pub elite_archive: Vec<CheckpointDraft>,
162 pub population: Vec<CheckpointDraft>,
163 pub parent_pool: Vec<CheckpointDraft>,
164 pub peak_population: usize,
165 pub peak_elite_archive: usize,
166 pub peak_semantic_archive: usize,
167 pub termination_state: String,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct PublicationPointer {
172 pub schema: u32,
173 pub run_id: String,
174 pub candidate_path: PathBuf,
175 pub manifest_path: PathBuf,
176 pub candidate_checksum: String,
177 pub manifest_checksum: String,
178}
179
180#[derive(Debug, Error)]
181pub enum ArtifactError {
182 #[error("cannot read artifact {path}: {source}")]
183 Read {
184 path: PathBuf,
185 source: std::io::Error,
186 },
187 #[error("invalid JSON on line {line} of {path}: {source}")]
188 JsonLine {
189 path: PathBuf,
190 line: usize,
191 source: serde_json::Error,
192 },
193 #[error("unsupported candidate schema {schema} on line {line} of {path}")]
194 Schema {
195 path: PathBuf,
196 line: usize,
197 schema: u32,
198 },
199 #[error("cannot serialize artifact: {0}")]
200 Serialize(#[from] serde_json::Error),
201 #[error("cannot atomically write artifact {path}: {source}")]
202 Write {
203 path: PathBuf,
204 source: std::io::Error,
205 },
206}
207
208pub fn read_candidates(path: &Path) -> Result<Vec<CandidateRecord>, ArtifactError> {
213 let file = File::open(path).map_err(|source| ArtifactError::Read {
214 path: path.to_owned(),
215 source,
216 })?;
217 let mut records = Vec::new();
218 for (index, line) in BufReader::new(file).lines().enumerate() {
219 let line_number = index + 1;
220 let line = line.map_err(|source| ArtifactError::Read {
221 path: path.to_owned(),
222 source,
223 })?;
224 if line.trim().is_empty() {
225 continue;
226 }
227 let record: CandidateRecord =
228 serde_json::from_str(&line).map_err(|source| ArtifactError::JsonLine {
229 path: path.to_owned(),
230 line: line_number,
231 source,
232 })?;
233 if record.schema == 0 || record.schema > CANDIDATE_SCHEMA {
234 return Err(ArtifactError::Schema {
235 path: path.to_owned(),
236 line: line_number,
237 schema: record.schema,
238 });
239 }
240 records.push(record);
241 }
242 Ok(records)
243}
244
245pub fn candidate_content(records: &[CandidateRecord]) -> Result<String, ArtifactError> {
250 let mut content = String::new();
251 for record in records {
252 content.push_str(&serde_json::to_string(record)?);
253 content.push('\n');
254 }
255 Ok(content)
256}
257
258pub fn write_jsonl_atomic(
263 path: &Path,
264 records: &[CandidateRecord],
265) -> Result<String, ArtifactError> {
266 let content = candidate_content(records)?;
267 atomic_write(path, content.as_bytes())?;
268 Ok(digest(&content))
269}
270
271pub fn write_manifest_atomic(path: &Path, manifest: &RunManifest) -> Result<(), ArtifactError> {
276 let mut content = serde_json::to_vec_pretty(manifest)?;
277 content.push(b'\n');
278 atomic_write(path, &content)
279}
280
281pub fn write_checkpoint_atomic(
286 path: &Path,
287 checkpoint: &RunCheckpoint,
288) -> Result<(), ArtifactError> {
289 let mut content = serde_json::to_vec(checkpoint)?;
290 content.push(b'\n');
291 atomic_write(path, &content)
292}
293
294pub fn read_checkpoint(path: &Path) -> Result<RunCheckpoint, ArtifactError> {
299 let content = fs::read(path).map_err(|source| ArtifactError::Read {
300 path: path.to_owned(),
301 source,
302 })?;
303 let checkpoint: RunCheckpoint =
304 serde_json::from_slice(&content).map_err(|source| ArtifactError::JsonLine {
305 path: path.to_owned(),
306 line: 1,
307 source,
308 })?;
309 if checkpoint.schema != CHECKPOINT_SCHEMA {
310 return Err(ArtifactError::Schema {
311 path: path.to_owned(),
312 line: 1,
313 schema: checkpoint.schema,
314 });
315 }
316 Ok(checkpoint)
317}
318
319#[must_use]
320pub fn manifest_path(output: &Path) -> PathBuf {
321 let name = output
322 .file_name()
323 .map_or_else(|| "candidates".into(), std::ffi::OsStr::to_os_string);
324 let mut adjacent = name;
325 adjacent.push(".manifest.json");
326 output.with_file_name(adjacent)
327}
328
329#[must_use]
330pub fn publication_pointer_path(output: &Path) -> PathBuf {
331 let name = output
332 .file_name()
333 .map_or_else(|| "candidates".into(), std::ffi::OsStr::to_os_string);
334 let mut adjacent = name;
335 adjacent.push(".current.json");
336 output.with_file_name(adjacent)
337}
338
339#[cfg_attr(not(unix), allow(clippy::unnecessary_wraps))]
350fn sync_directory(path: &Path) -> std::io::Result<()> {
351 #[cfg(unix)]
352 {
353 File::open(path)?.sync_all()?;
354 }
355 #[cfg(not(unix))]
356 {
357 let _ = path;
358 }
359 Ok(())
360}
361
362pub fn write_run_transactional(
370 output: &Path,
371 records: &[CandidateRecord],
372 manifest: &RunManifest,
373) -> Result<PublicationPointer, ArtifactError> {
374 if records
375 .iter()
376 .any(|record| record.run_id != manifest.run_id)
377 {
378 return Err(ArtifactError::Write {
379 path: output.to_owned(),
380 source: std::io::Error::new(
381 std::io::ErrorKind::InvalidData,
382 "candidate and manifest run IDs differ",
383 ),
384 });
385 }
386 let candidate_bytes = candidate_content(records)?.into_bytes();
387 let candidate_checksum = digest(std::str::from_utf8(&candidate_bytes).unwrap_or_default());
388 if candidate_checksum != manifest.candidate_content_checksum {
389 return Err(ArtifactError::Write {
390 path: output.to_owned(),
391 source: std::io::Error::new(
392 std::io::ErrorKind::InvalidData,
393 "candidate checksum does not match manifest",
394 ),
395 });
396 }
397 let mut manifest_bytes = serde_json::to_vec_pretty(manifest)?;
398 manifest_bytes.push(b'\n');
399 let manifest_checksum = digest(std::str::from_utf8(&manifest_bytes).unwrap_or_default());
400 let parent = output.parent().unwrap_or_else(|| Path::new("."));
401 let file_name = output.file_name().unwrap_or_default().to_string_lossy();
402 let runs = parent.join(format!(".{file_name}.runs"));
403 fs::create_dir_all(&runs).map_err(|source| ArtifactError::Write {
404 path: runs.clone(),
405 source,
406 })?;
407 let version = format!(
408 "{}-{}-{}",
409 &manifest.run_id[..manifest.run_id.len().min(16)],
410 &candidate_checksum[..candidate_checksum.len().min(16)],
411 &manifest_checksum[..manifest_checksum.len().min(16)]
412 );
413 let final_directory = runs.join(version);
414 if !final_directory.exists() {
415 let sequence = TEMPORARY_SEQUENCE.fetch_add(1, Ordering::Relaxed);
416 let staging = runs.join(format!(".stage-{}-{sequence}", std::process::id()));
417 fs::create_dir(&staging).map_err(|source| ArtifactError::Write {
418 path: staging.clone(),
419 source,
420 })?;
421 let result = (|| -> Result<(), std::io::Error> {
422 write_new_file(&staging.join("candidates.jsonl"), &candidate_bytes)?;
423 write_new_file(&staging.join("manifest.json"), &manifest_bytes)?;
424 sync_directory(&staging)?;
425 fs::rename(&staging, &final_directory)?;
426 sync_directory(&runs)?;
427 Ok(())
428 })();
429 if let Err(source) = result {
430 let _ = fs::remove_dir_all(&staging);
431 return Err(ArtifactError::Write {
432 path: final_directory,
433 source,
434 });
435 }
436 }
437 let pointer = PublicationPointer {
438 schema: 1,
439 run_id: manifest.run_id.clone(),
440 candidate_path: final_directory.join("candidates.jsonl"),
441 manifest_path: final_directory.join("manifest.json"),
442 candidate_checksum,
443 manifest_checksum,
444 };
445 let mut pointer_bytes = serde_json::to_vec_pretty(&pointer)?;
446 pointer_bytes.push(b'\n');
447 atomic_write(&publication_pointer_path(output), &pointer_bytes)?;
448 write_jsonl_atomic(output, records)?;
449 write_manifest_atomic(&manifest_path(output), manifest)?;
450 Ok(pointer)
451}
452
453pub fn read_published_run(
459 output: &Path,
460) -> Result<(Vec<CandidateRecord>, RunManifest), ArtifactError> {
461 let pointer_path = publication_pointer_path(output);
462 let pointer_bytes = fs::read(&pointer_path).map_err(|source| ArtifactError::Read {
463 path: pointer_path.clone(),
464 source,
465 })?;
466 let pointer: PublicationPointer =
467 serde_json::from_slice(&pointer_bytes).map_err(|source| ArtifactError::JsonLine {
468 path: pointer_path,
469 line: 1,
470 source,
471 })?;
472 if pointer.schema != 1 {
473 return Err(ArtifactError::Schema {
474 path: publication_pointer_path(output),
475 line: 1,
476 schema: pointer.schema,
477 });
478 }
479 let candidate_bytes =
480 fs::read(&pointer.candidate_path).map_err(|source| ArtifactError::Read {
481 path: pointer.candidate_path.clone(),
482 source,
483 })?;
484 let records = read_candidates(&pointer.candidate_path)?;
485 let manifest_bytes =
486 fs::read(&pointer.manifest_path).map_err(|source| ArtifactError::Read {
487 path: pointer.manifest_path.clone(),
488 source,
489 })?;
490 let manifest: RunManifest =
491 serde_json::from_slice(&manifest_bytes).map_err(|source| ArtifactError::JsonLine {
492 path: pointer.manifest_path,
493 line: 1,
494 source,
495 })?;
496 if manifest.run_id != pointer.run_id
497 || records.iter().any(|record| record.run_id != pointer.run_id)
498 {
499 return Err(ArtifactError::Write {
500 path: output.to_owned(),
501 source: std::io::Error::new(
502 std::io::ErrorKind::InvalidData,
503 "published pair has inconsistent run IDs",
504 ),
505 });
506 }
507 let candidate_checksum = digest(std::str::from_utf8(&candidate_bytes).unwrap_or_default());
508 let manifest_checksum = digest(std::str::from_utf8(&manifest_bytes).unwrap_or_default());
509 if candidate_checksum != pointer.candidate_checksum
510 || candidate_checksum != manifest.candidate_content_checksum
511 {
512 return Err(ArtifactError::Write {
513 path: output.to_owned(),
514 source: std::io::Error::new(
515 std::io::ErrorKind::InvalidData,
516 "published candidate checksum verification failed",
517 ),
518 });
519 }
520 if manifest_checksum != pointer.manifest_checksum {
521 return Err(ArtifactError::Write {
522 path: output.to_owned(),
523 source: std::io::Error::new(
524 std::io::ErrorKind::InvalidData,
525 "published manifest checksum verification failed",
526 ),
527 });
528 }
529 Ok((records, manifest))
530}
531
532fn write_new_file(path: &Path, content: &[u8]) -> Result<(), std::io::Error> {
533 let mut file = OpenOptions::new().write(true).create_new(true).open(path)?;
534 file.write_all(content)?;
535 file.sync_all()
536}
537
538pub(crate) fn atomic_write(path: &Path, content: &[u8]) -> Result<(), ArtifactError> {
539 let parent = path.parent().unwrap_or_else(|| Path::new("."));
540 let file_name = path.file_name().unwrap_or_default().to_string_lossy();
541 let sequence = TEMPORARY_SEQUENCE.fetch_add(1, Ordering::Relaxed);
542 let temporary = parent.join(format!(
543 ".{file_name}.{}.{sequence}.tmp",
544 std::process::id()
545 ));
546 let result = (|| -> Result<(), std::io::Error> {
547 let mut file = OpenOptions::new()
548 .write(true)
549 .create_new(true)
550 .open(&temporary)?;
551 file.write_all(content)?;
552 file.sync_all()?;
553 drop(file);
554 fs::rename(&temporary, path)?;
555 let _ = sync_directory(parent);
556 Ok(())
557 })();
558 if let Err(source) = result {
559 let _ = fs::remove_file(&temporary);
560 return Err(ArtifactError::Write {
561 path: path.to_owned(),
562 source,
563 });
564 }
565 Ok(())
566}
567
568#[cfg(test)]
569mod tests {
570 use tempfile::tempdir;
571
572 use super::*;
573 use crate::transform::{Provenance, TransformKind};
574
575 fn record(expression: &str) -> CandidateRecord {
576 CandidateRecord {
577 schema: CANDIDATE_SCHEMA,
578 run_id: "test-run".to_owned(),
579 expression: expression.to_owned(),
580 fingerprint: "a".repeat(64),
581 semantic_fingerprint: "b".repeat(64),
582 structural_score: 0.5,
583 score_schema: 2,
584 score_components: StructuralScoreComponents::default(),
585 normalized_weights: StructuralScoreWeights::default(),
586 structural_descriptor: StructuralDescriptor::default(),
587 root_family: "field".to_owned(),
588 nodes: 1,
589 depth: 1,
590 provenance: Provenance {
591 kind: TransformKind::Initial,
592 operation: "test".to_owned(),
593 parent_fingerprints: Vec::new(),
594 requested_operation: None,
595 affected_path: None,
596 old_subtree_fingerprint: None,
597 new_subtree_fingerprint: None,
598 retry_count: 0,
599 },
600 }
601 }
602
603 #[test]
604 fn atomic_jsonl_replaces_complete_artifact_and_leaves_no_temp() {
605 let directory = tempdir().unwrap();
606 let path = directory.path().join("out.jsonl");
607 fs::write(&path, "old\n").unwrap();
608 write_jsonl_atomic(&path, &[record("close")]).unwrap();
609 assert_eq!(read_candidates(&path).unwrap()[0].expression, "close");
610 assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 1);
611 }
612}