2026-04-02 23:11:15 -07:00
|
|
|
use std::collections::BTreeSet;
|
|
|
|
|
use std::env;
|
|
|
|
|
use std::fs;
|
|
|
|
|
use std::io::Read;
|
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
|
|
|
|
|
use rrt_model::{
|
|
|
|
|
BINARY_SUMMARY_PATH, CANONICAL_EXE_PATH, CONTROL_LOOP_ATLAS_PATH, FUNCTION_MAP_PATH,
|
2026-04-08 16:31:33 -07:00
|
|
|
REQUIRED_ATLAS_HEADINGS, REQUIRED_EXPORTS,
|
|
|
|
|
finance::{FinanceOutcome, FinanceSnapshot},
|
|
|
|
|
load_binary_summary, load_function_map,
|
2026-04-02 23:11:15 -07:00
|
|
|
};
|
2026-04-08 16:31:33 -07:00
|
|
|
use serde::Serialize;
|
|
|
|
|
use serde_json::Value;
|
2026-04-02 23:11:15 -07:00
|
|
|
use sha2::{Digest, Sha256};
|
|
|
|
|
|
2026-04-08 16:31:33 -07:00
|
|
|
enum Command {
|
|
|
|
|
Validate { repo_root: PathBuf },
|
|
|
|
|
FinanceEval { snapshot_path: PathBuf },
|
|
|
|
|
FinanceDiff { left_path: PathBuf, right_path: PathBuf },
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Serialize)]
|
|
|
|
|
struct FinanceDiffEntry {
|
|
|
|
|
path: String,
|
|
|
|
|
left: Value,
|
|
|
|
|
right: Value,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Serialize)]
|
|
|
|
|
struct FinanceDiffReport {
|
|
|
|
|
matches: bool,
|
|
|
|
|
difference_count: usize,
|
|
|
|
|
differences: Vec<FinanceDiffEntry>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 23:11:15 -07:00
|
|
|
fn main() {
|
|
|
|
|
if let Err(err) = real_main() {
|
|
|
|
|
eprintln!("error: {err}");
|
|
|
|
|
std::process::exit(1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn real_main() -> Result<(), Box<dyn std::error::Error>> {
|
2026-04-08 16:31:33 -07:00
|
|
|
match parse_command()? {
|
|
|
|
|
Command::Validate { repo_root } => {
|
|
|
|
|
validate_required_files(&repo_root)?;
|
|
|
|
|
validate_binary_summary(&repo_root)?;
|
|
|
|
|
validate_function_map(&repo_root)?;
|
|
|
|
|
validate_control_loop_atlas(&repo_root)?;
|
|
|
|
|
println!("baseline validation passed");
|
|
|
|
|
}
|
|
|
|
|
Command::FinanceEval { snapshot_path } => {
|
|
|
|
|
run_finance_eval(&snapshot_path)?;
|
|
|
|
|
}
|
|
|
|
|
Command::FinanceDiff {
|
|
|
|
|
left_path,
|
|
|
|
|
right_path,
|
|
|
|
|
} => {
|
|
|
|
|
run_finance_diff(&left_path, &right_path)?;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 23:11:15 -07:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 16:31:33 -07:00
|
|
|
fn parse_command() -> Result<Command, Box<dyn std::error::Error>> {
|
2026-04-02 23:11:15 -07:00
|
|
|
let mut args = env::args().skip(1);
|
2026-04-08 16:31:33 -07:00
|
|
|
match (args.next().as_deref(), args.next(), args.next(), args.next()) {
|
|
|
|
|
(None, None, None, None) => Ok(Command::Validate {
|
|
|
|
|
repo_root: env::current_dir()?,
|
|
|
|
|
}),
|
|
|
|
|
(Some("validate"), None, None, None) => Ok(Command::Validate {
|
|
|
|
|
repo_root: env::current_dir()?,
|
|
|
|
|
}),
|
|
|
|
|
(Some("validate"), Some(path), None, None) => Ok(Command::Validate {
|
|
|
|
|
repo_root: PathBuf::from(path),
|
|
|
|
|
}),
|
|
|
|
|
(Some("finance"), Some(subcommand), Some(path), None) if subcommand == "eval" => {
|
|
|
|
|
Ok(Command::FinanceEval {
|
|
|
|
|
snapshot_path: PathBuf::from(path),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
(Some("finance"), Some(subcommand), Some(left), Some(right)) if subcommand == "diff" => {
|
|
|
|
|
Ok(Command::FinanceDiff {
|
|
|
|
|
left_path: PathBuf::from(left),
|
|
|
|
|
right_path: PathBuf::from(right),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
_ => Err(
|
|
|
|
|
"usage: rrt-cli [validate [repo-root] | finance eval <snapshot.json> | finance diff <left.json> <right.json>]"
|
|
|
|
|
.into(),
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn run_finance_eval(snapshot_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
|
|
|
|
let outcome = load_finance_outcome(snapshot_path)?;
|
|
|
|
|
println!("{}", serde_json::to_string_pretty(&outcome)?);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn run_finance_diff(
|
|
|
|
|
left_path: &Path,
|
|
|
|
|
right_path: &Path,
|
|
|
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
|
|
|
let left = load_finance_outcome(left_path)?;
|
|
|
|
|
let right = load_finance_outcome(right_path)?;
|
|
|
|
|
let report = diff_finance_outcomes(&left, &right)?;
|
|
|
|
|
println!("{}", serde_json::to_string_pretty(&report)?);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn load_finance_outcome(path: &Path) -> Result<FinanceOutcome, Box<dyn std::error::Error>> {
|
|
|
|
|
let text = fs::read_to_string(path)?;
|
|
|
|
|
if let Ok(snapshot) = serde_json::from_str::<FinanceSnapshot>(&text) {
|
|
|
|
|
return Ok(snapshot.evaluate());
|
|
|
|
|
}
|
|
|
|
|
if let Ok(outcome) = serde_json::from_str::<FinanceOutcome>(&text) {
|
|
|
|
|
return Ok(outcome);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Err(format!(
|
|
|
|
|
"unable to parse {} as FinanceSnapshot or FinanceOutcome",
|
|
|
|
|
path.display()
|
|
|
|
|
)
|
|
|
|
|
.into())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn diff_finance_outcomes(
|
|
|
|
|
left: &FinanceOutcome,
|
|
|
|
|
right: &FinanceOutcome,
|
|
|
|
|
) -> Result<FinanceDiffReport, Box<dyn std::error::Error>> {
|
|
|
|
|
let left_value = serde_json::to_value(left)?;
|
|
|
|
|
let right_value = serde_json::to_value(right)?;
|
|
|
|
|
let mut differences = Vec::new();
|
|
|
|
|
collect_json_differences("$", &left_value, &right_value, &mut differences);
|
|
|
|
|
|
|
|
|
|
Ok(FinanceDiffReport {
|
|
|
|
|
matches: differences.is_empty(),
|
|
|
|
|
difference_count: differences.len(),
|
|
|
|
|
differences,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn collect_json_differences(
|
|
|
|
|
path: &str,
|
|
|
|
|
left: &Value,
|
|
|
|
|
right: &Value,
|
|
|
|
|
differences: &mut Vec<FinanceDiffEntry>,
|
|
|
|
|
) {
|
|
|
|
|
match (left, right) {
|
|
|
|
|
(Value::Object(left_map), Value::Object(right_map)) => {
|
|
|
|
|
let mut keys = BTreeSet::new();
|
|
|
|
|
keys.extend(left_map.keys().cloned());
|
|
|
|
|
keys.extend(right_map.keys().cloned());
|
|
|
|
|
|
|
|
|
|
for key in keys {
|
|
|
|
|
let next_path = format!("{path}.{key}");
|
|
|
|
|
match (left_map.get(&key), right_map.get(&key)) {
|
|
|
|
|
(Some(left_value), Some(right_value)) => {
|
|
|
|
|
collect_json_differences(&next_path, left_value, right_value, differences);
|
|
|
|
|
}
|
|
|
|
|
(left_value, right_value) => differences.push(FinanceDiffEntry {
|
|
|
|
|
path: next_path,
|
|
|
|
|
left: left_value.cloned().unwrap_or(Value::Null),
|
|
|
|
|
right: right_value.cloned().unwrap_or(Value::Null),
|
|
|
|
|
}),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
(Value::Array(left_items), Value::Array(right_items)) => {
|
|
|
|
|
let max_len = left_items.len().max(right_items.len());
|
|
|
|
|
for index in 0..max_len {
|
|
|
|
|
let next_path = format!("{path}[{index}]");
|
|
|
|
|
match (left_items.get(index), right_items.get(index)) {
|
|
|
|
|
(Some(left_value), Some(right_value)) => {
|
|
|
|
|
collect_json_differences(&next_path, left_value, right_value, differences);
|
|
|
|
|
}
|
|
|
|
|
(left_value, right_value) => differences.push(FinanceDiffEntry {
|
|
|
|
|
path: next_path,
|
|
|
|
|
left: left_value.cloned().unwrap_or(Value::Null),
|
|
|
|
|
right: right_value.cloned().unwrap_or(Value::Null),
|
|
|
|
|
}),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ if left != right => differences.push(FinanceDiffEntry {
|
|
|
|
|
path: path.to_string(),
|
|
|
|
|
left: left.clone(),
|
|
|
|
|
right: right.clone(),
|
|
|
|
|
}),
|
|
|
|
|
_ => {}
|
2026-04-02 23:11:15 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn validate_required_files(repo_root: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
|
|
|
|
let mut missing = Vec::new();
|
|
|
|
|
for relative in REQUIRED_EXPORTS {
|
|
|
|
|
let path = repo_root.join(relative);
|
|
|
|
|
if !path.exists() {
|
|
|
|
|
missing.push(path.display().to_string());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !missing.is_empty() {
|
|
|
|
|
return Err(format!("missing required exports: {}", missing.join(", ")).into());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn validate_binary_summary(repo_root: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
|
|
|
|
let summary = load_binary_summary(&repo_root.join(BINARY_SUMMARY_PATH))?;
|
|
|
|
|
let actual_exe = repo_root.join(CANONICAL_EXE_PATH);
|
|
|
|
|
if !actual_exe.exists() {
|
|
|
|
|
return Err(format!("canonical exe missing: {}", actual_exe.display()).into());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let actual_hash = sha256_file(&actual_exe)?;
|
|
|
|
|
if actual_hash != summary.sha256 {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"hash mismatch for {}: summary has {}, actual file is {}",
|
|
|
|
|
actual_exe.display(),
|
|
|
|
|
summary.sha256,
|
|
|
|
|
actual_hash
|
|
|
|
|
)
|
|
|
|
|
.into());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let docs_readme = fs::read_to_string(repo_root.join("docs/README.md"))?;
|
|
|
|
|
if !docs_readme.contains(&summary.sha256) {
|
|
|
|
|
return Err("docs/README.md does not include the canonical SHA-256".into());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn validate_function_map(repo_root: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
|
|
|
|
let records = load_function_map(&repo_root.join(FUNCTION_MAP_PATH))?;
|
|
|
|
|
let mut seen = BTreeSet::new();
|
|
|
|
|
|
|
|
|
|
for record in records {
|
|
|
|
|
if !(1..=5).contains(&record.confidence) {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"invalid confidence {} for {} {}",
|
|
|
|
|
record.confidence, record.address, record.name
|
|
|
|
|
)
|
|
|
|
|
.into());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !seen.insert(record.address) {
|
|
|
|
|
return Err(format!("duplicate function address {}", record.address).into());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if record.name.trim().is_empty() {
|
|
|
|
|
return Err(format!("blank function name at {}", record.address).into());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn validate_control_loop_atlas(repo_root: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
|
|
|
|
let atlas = fs::read_to_string(repo_root.join(CONTROL_LOOP_ATLAS_PATH))?;
|
|
|
|
|
for heading in REQUIRED_ATLAS_HEADINGS {
|
|
|
|
|
if !atlas.contains(heading) {
|
|
|
|
|
return Err(format!("missing atlas heading `{heading}`").into());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for marker in [
|
|
|
|
|
"- Roots:",
|
|
|
|
|
"- Trigger/Cadence:",
|
|
|
|
|
"- Key Dispatchers:",
|
|
|
|
|
"- State Anchors:",
|
|
|
|
|
"- Subsystem Handoffs:",
|
|
|
|
|
"- Evidence:",
|
|
|
|
|
"- Open Questions:",
|
|
|
|
|
] {
|
|
|
|
|
if !atlas.contains(marker) {
|
|
|
|
|
return Err(format!("atlas is missing field marker `{marker}`").into());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn sha256_file(path: &Path) -> Result<String, Box<dyn std::error::Error>> {
|
|
|
|
|
let mut file = fs::File::open(path)?;
|
|
|
|
|
let mut hasher = Sha256::new();
|
|
|
|
|
let mut buffer = [0_u8; 8192];
|
|
|
|
|
loop {
|
|
|
|
|
let read = file.read(&mut buffer)?;
|
|
|
|
|
if read == 0 {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
hasher.update(&buffer[..read]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(format!("{:x}", hasher.finalize()))
|
|
|
|
|
}
|
2026-04-08 16:31:33 -07:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use rrt_model::finance::{AnnualFinanceDecision, AnnualFinanceEvaluation, CompanyFinanceState, DebtRestructureSummary};
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn loads_snapshot_as_outcome() {
|
|
|
|
|
let snapshot = FinanceSnapshot {
|
|
|
|
|
policy: rrt_model::finance::AnnualFinancePolicy {
|
|
|
|
|
dividends_allowed: false,
|
|
|
|
|
..rrt_model::finance::AnnualFinancePolicy::default()
|
|
|
|
|
},
|
|
|
|
|
company: CompanyFinanceState::default(),
|
|
|
|
|
};
|
|
|
|
|
let path = write_temp_json("snapshot", &snapshot);
|
|
|
|
|
|
|
|
|
|
let outcome = load_finance_outcome(&path).expect("snapshot should load");
|
|
|
|
|
assert_eq!(outcome.evaluation.decision, AnnualFinanceDecision::NoAction);
|
|
|
|
|
|
|
|
|
|
let _ = fs::remove_file(path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn diffs_outcomes_recursively() {
|
|
|
|
|
let left = FinanceOutcome {
|
|
|
|
|
evaluation: AnnualFinanceEvaluation::no_action(),
|
|
|
|
|
post_company: CompanyFinanceState::default(),
|
|
|
|
|
};
|
|
|
|
|
let mut right = left.clone();
|
|
|
|
|
right.post_company.current_cash = 123;
|
|
|
|
|
right.evaluation.debt_restructure = DebtRestructureSummary {
|
|
|
|
|
retired_principal: 10,
|
|
|
|
|
issued_principal: 20,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let report = diff_finance_outcomes(&left, &right).expect("diff should succeed");
|
|
|
|
|
assert!(!report.matches);
|
|
|
|
|
assert!(report
|
|
|
|
|
.differences
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|entry| entry.path == "$.post_company.current_cash"));
|
|
|
|
|
assert!(report
|
|
|
|
|
.differences
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|entry| entry.path == "$.evaluation.debt_restructure.retired_principal"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn write_temp_json<T: Serialize>(stem: &str, value: &T) -> PathBuf {
|
|
|
|
|
let nonce = std::time::SystemTime::now()
|
|
|
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
|
|
|
.expect("system time should be after epoch")
|
|
|
|
|
.as_nanos();
|
|
|
|
|
let path = std::env::temp_dir().join(format!("rrt-cli-{stem}-{nonce}.json"));
|
|
|
|
|
let bytes = serde_json::to_vec_pretty(value).expect("json serialization should succeed");
|
|
|
|
|
fs::write(&path, bytes).expect("temp json should be written");
|
|
|
|
|
path
|
|
|
|
|
}
|
|
|
|
|
}
|