1use 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}