Add hook debug tooling and refine RT3 atlas

This commit is contained in:
Jan Petykiewicz 2026-04-08 16:31:33 -07:00
commit 57bf0666e0
38 changed files with 14437 additions and 873 deletions

View file

@ -6,4 +6,6 @@ license.workspace = true
[dependencies]
rrt-model = { path = "../rrt-model" }
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true

View file

@ -6,10 +6,34 @@ use std::path::{Path, PathBuf};
use rrt_model::{
BINARY_SUMMARY_PATH, CANONICAL_EXE_PATH, CONTROL_LOOP_ATLAS_PATH, FUNCTION_MAP_PATH,
REQUIRED_ATLAS_HEADINGS, REQUIRED_EXPORTS, load_binary_summary, load_function_map,
REQUIRED_ATLAS_HEADINGS, REQUIRED_EXPORTS,
finance::{FinanceOutcome, FinanceSnapshot},
load_binary_summary, load_function_map,
};
use serde::Serialize;
use serde_json::Value;
use sha2::{Digest, Sha256};
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>,
}
fn main() {
if let Err(err) = real_main() {
eprintln!("error: {err}");
@ -18,22 +42,155 @@ fn main() {
}
fn real_main() -> Result<(), Box<dyn std::error::Error>> {
let repo_root = parse_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");
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)?;
}
}
Ok(())
}
fn parse_repo_root() -> Result<PathBuf, Box<dyn std::error::Error>> {
fn parse_command() -> Result<Command, Box<dyn std::error::Error>> {
let mut args = env::args().skip(1);
match (args.next().as_deref(), args.next(), args.next()) {
(None, None, None) => Ok(env::current_dir()?),
(Some("validate"), None, None) => Ok(env::current_dir()?),
(Some("validate"), Some(path), None) => Ok(PathBuf::from(path)),
_ => Err("usage: rrt-cli [validate [repo-root]]".into()),
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(),
}),
_ => {}
}
}
@ -143,3 +300,62 @@ fn sha256_file(path: &Path) -> Result<String, Box<dyn std::error::Error>> {
Ok(format!("{:x}", hasher.finalize()))
}
#[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
}
}