Skip to main content

p6_soak/
p6_soak.rs

1//! ADR-119 S1 bounded lifecycle soak and byte-equality certification.
2//!
3//! Run against a disposable generated corpus:
4//!
5//! `p6_soak <corpus> <cache-root> [rounds]`
6
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use rac_engine::derived::{build_derived_index, DerivedIndex, SCHEMA_VERSION};
11use rac_engine::freshness::{FreshnessTracker, TrackerModel};
12use rac_engine::index_store::{store_dir, write_store};
13use serde_json::json;
14
15fn first_markdown(root: &Path) -> PathBuf {
16    rac_engine::walk::find_markdown_files(&root.to_string_lossy(), true)
17        .into_iter()
18        .next()
19        .map(|entry| root.join(entry.components.join("/")))
20        .expect("corpus must contain at least one markdown file")
21}
22
23fn store_hashes(cache: &Path, key: &str) -> Vec<(String, String)> {
24    let mut hashes: Vec<(String, String)> = fs::read_dir(store_dir(cache, key))
25        .expect("read persisted store")
26        .map(|entry| {
27            let entry = entry.expect("read store entry");
28            let bytes = fs::read(entry.path()).expect("read store segment");
29            (
30                entry.file_name().to_string_lossy().into_owned(),
31                rac_engine::sha256::hexdigest(&bytes),
32            )
33        })
34        .collect();
35    hashes.sort();
36    hashes
37}
38
39fn assert_valid(root: &str) {
40    let items = rac_engine::relationships::corpus_items(root, true);
41    for item in &items {
42        let Some(spec) = item.spec else {
43            continue;
44        };
45        let issues = rac_engine::validate::validate(&item.artifact, None, Some(&spec.name));
46        assert!(
47            !rac_engine::validate::has_errors(&issues),
48            "{} must remain valid: {issues:?}",
49            item.path
50        );
51    }
52    let relationships = rac_engine::relationships::validate_relationships(root, true);
53    assert!(
54        relationships.ok(),
55        "relationship validation must remain clean: {:?}",
56        relationships.issues
57    );
58}
59
60fn certify_generation(
61    tracker: &mut FreshnessTracker,
62    verify: bool,
63    root: &str,
64    tracker_cache: &Path,
65    referee_root: &Path,
66    stage: &str,
67) {
68    let materialized: Option<DerivedIndex> = match tracker.read_model(verify) {
69        TrackerModel::Delta(generation) => Some(generation.materialize_derived(root, true)),
70        TrackerModel::View(_) => None,
71        TrackerModel::Snapshot(_) => panic!("S1 delta soak served a snapshot at {stage}"),
72    };
73    let key = tracker
74        .corpus_hash()
75        .expect("fresh generation must have a corpus hash")
76        .to_string();
77    let candidate_cache = referee_root.join(stage).join("candidate");
78    let fresh_cache = referee_root.join(stage).join("fresh");
79    let _ = fs::remove_dir_all(&candidate_cache);
80    let _ = fs::remove_dir_all(&fresh_cache);
81
82    let fresh = build_derived_index(root, true);
83    assert!(write_store(&fresh_cache, &key, SCHEMA_VERSION, &fresh));
84    let candidate_cache = if let Some(candidate) = materialized {
85        assert!(write_store(
86            &candidate_cache,
87            &key,
88            SCHEMA_VERSION,
89            &candidate,
90        ));
91        candidate_cache.as_path()
92    } else {
93        tracker_cache
94    };
95    assert_eq!(
96        store_hashes(candidate_cache, &key),
97        store_hashes(&fresh_cache, &key),
98        "persisted segments diverged from a fresh no-cache derivation at {stage}"
99    );
100}
101
102fn main() {
103    let args: Vec<String> = std::env::args().collect();
104    if args.len() < 3 {
105        eprintln!("usage: p6_soak <corpus> <cache-root> [rounds]");
106        std::process::exit(2);
107    }
108    let corpus = PathBuf::from(&args[1]);
109    let cache_root = PathBuf::from(&args[2]);
110    let rounds: usize = args
111        .get(3)
112        .and_then(|value| value.parse().ok())
113        .unwrap_or(3);
114    let root = corpus.to_string_lossy().into_owned();
115    let mutation_cache = cache_root.join("mutation");
116    let referee_root = cache_root.join("referees");
117    let _ = fs::remove_dir_all(&cache_root);
118    fs::create_dir_all(&cache_root).expect("create soak cache");
119    assert_valid(&root);
120
121    let mut tracker = FreshnessTracker::new(
122        mutation_cache.clone(),
123        &root,
124        Some(rounds.saturating_mul(16).saturating_add(100)),
125    );
126    certify_generation(
127        &mut tracker,
128        true,
129        &root,
130        &mutation_cache,
131        &referee_root,
132        "cold",
133    );
134    let mut warm_reads = 0;
135    for _ in 0..100 {
136        tracker.read_model(false);
137        warm_reads += 1;
138    }
139
140    let target = first_markdown(&corpus);
141    let original = fs::read(&target).expect("read mutation target");
142    let mut transitions = 0;
143    for round in 0..rounds {
144        let mut edited = original.clone();
145        edited.extend_from_slice(format!("\n<!-- p6-soak-{round} -->\n").as_bytes());
146        fs::write(&target, &edited).expect("write edit");
147        certify_generation(
148            &mut tracker,
149            true,
150            &root,
151            &mutation_cache,
152            &referee_root,
153            &format!("round-{round}-edit"),
154        );
155        assert_eq!(tracker.last_parse_files(), 1);
156        transitions += 1;
157
158        fs::write(&target, &original).expect("restore edit");
159        certify_generation(
160            &mut tracker,
161            true,
162            &root,
163            &mutation_cache,
164            &referee_root,
165            &format!("round-{round}-restore"),
166        );
167        assert_eq!(tracker.last_parse_files(), 1);
168        transitions += 1;
169
170        let added = corpus.join(format!("p6-soak-added-{round}.md"));
171        let added_text = format!(
172            "---\nschema_version: 1\nid: RAC-Z9SCAE{round:06}\ntype: decision\n---\n# Soak {round}\n\n## Context\n\nBounded soak.\n\n## Decision\n\nCertify.\n\n## Consequences\n\nEvidence.\n\n## Status\n\nAccepted\n"
173        );
174        fs::write(&added, added_text).expect("write added artifact");
175        certify_generation(
176            &mut tracker,
177            true,
178            &root,
179            &mutation_cache,
180            &referee_root,
181            &format!("round-{round}-add"),
182        );
183        assert_eq!(tracker.last_parse_files(), 1);
184        transitions += 1;
185
186        fs::remove_file(&added).expect("delete added artifact");
187        certify_generation(
188            &mut tracker,
189            true,
190            &root,
191            &mutation_cache,
192            &referee_root,
193            &format!("round-{round}-delete"),
194        );
195        assert_eq!(tracker.last_parse_files(), 0);
196        transitions += 1;
197
198        let renamed = target.with_extension(format!("soak-{round}.md"));
199        fs::rename(&target, &renamed).expect("rename target");
200        certify_generation(
201            &mut tracker,
202            true,
203            &root,
204            &mutation_cache,
205            &referee_root,
206            &format!("round-{round}-rename"),
207        );
208        assert_eq!(tracker.last_parse_files(), 1);
209        transitions += 1;
210        fs::rename(&renamed, &target).expect("restore target name");
211        certify_generation(
212            &mut tracker,
213            true,
214            &root,
215            &mutation_cache,
216            &referee_root,
217            &format!("round-{round}-rename-back"),
218        );
219        assert_eq!(tracker.last_parse_files(), 1);
220        transitions += 1;
221    }
222
223    let compaction_cache = cache_root.join("compaction");
224    let compaction_referees = cache_root.join("compaction-referees");
225    let mut compact = FreshnessTracker::new(compaction_cache.clone(), &root, Some(1));
226    certify_generation(
227        &mut compact,
228        true,
229        &root,
230        &compaction_cache,
231        &compaction_referees,
232        "compact-cold",
233    );
234    let mut edited = original.clone();
235    edited.extend_from_slice(b"\n<!-- p6-soak-compaction -->\n");
236    fs::write(&target, &edited).expect("write compaction edit");
237    certify_generation(
238        &mut compact,
239        true,
240        &root,
241        &compaction_cache,
242        &compaction_referees,
243        "compact-edit",
244    );
245    assert_eq!(compact.last_parse_files(), 1);
246    assert_eq!(compact.delta_size(), 0);
247    fs::write(&target, &original).expect("write first post-compaction edit");
248    certify_generation(
249        &mut compact,
250        true,
251        &root,
252        &compaction_cache,
253        &compaction_referees,
254        "compact-post-edit",
255    );
256    assert_eq!(compact.last_parse_files(), 1);
257    assert_eq!(compact.delta_size(), 0);
258    assert_valid(&root);
259
260    println!(
261        "{}",
262        serde_json::to_string_pretty(&json!({
263            "schema_version": "1",
264            "files": rac_engine::walk::find_markdown_files(&root, true).len(),
265            "rounds": rounds,
266            "warm_reads": warm_reads,
267            "certified_transitions": transitions + 3,
268            "valid": true,
269            "deterministic": true,
270            "fresh": true,
271            "cache_no_cache_equal": true,
272            "persisted_segments_byte_equal": true,
273            "post_compaction_parse_files": compact.last_parse_files(),
274        }))
275        .unwrap()
276    );
277}