use std::collections::{BTreeMap, HashMap, HashSet};
use serde::{Deserialize, Serialize};
use crate::artifact_model::Tool;
use crate::dag::Dag;
use crate::finding::LintFinding;
use crate::manifest_model::{InputTier, Manifest, Role};
use crate::sheet_ir::value::CellValue;
use crate::sheet_ir::{run as run_executor, Cell, CellEnv};
pub const TOL: f64 = 0.01;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct OutputRow {
pub key: String,
pub cell: Option<String>,
pub server_value: Option<CellValue>,
pub oracle_value: Option<CellValue>,
pub abs_delta: f64,
pub within_tol: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ToolReport {
pub tool: String,
pub all_within_tol: bool,
pub outputs: Vec<OutputRow>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ReconcileReport {
pub tolerance: f64,
pub all_within_tol: bool,
pub cells_checked: u32,
pub tools: Vec<ToolReport>,
}
#[must_use]
pub fn seed_reference_inputs(manifest: &Manifest) -> BTreeMap<String, CellValue> {
let mut seeds = BTreeMap::new();
for role in &manifest.cells {
if !matches!(role.role, Role::Input) {
continue;
}
match &role.tier {
Some(InputTier::Variable { default })
| Some(InputTier::BoundedVariable { default, .. }) => {
seeds.insert(role.cell.clone(), default.clone());
},
None => {},
}
}
seeds
}
#[must_use]
fn compare_output(server: Option<&CellValue>, oracle: Option<&CellValue>) -> (f64, bool) {
match (server, oracle) {
(Some(CellValue::Number(s)), Some(CellValue::Number(o)))
if s.is_finite() && o.is_finite() =>
{
let delta = (s - o).abs();
(delta, delta <= TOL)
},
(Some(CellValue::Text(s)), Some(CellValue::Text(o))) => discrete_eq(s == o),
(Some(CellValue::Bool(s)), Some(CellValue::Bool(o))) => discrete_eq(s == o),
_ => (1.0, false),
}
}
#[must_use]
fn discrete_eq(equal: bool) -> (f64, bool) {
if equal {
(0.0, true)
} else {
(1.0, false)
}
}
fn reconcile_tool(tool: &Tool, computed: &HashMap<String, CellValue>) -> (ToolReport, u32) {
let mut rows = Vec::new();
let mut matched_keys: HashSet<&str> = HashSet::new();
for entry in &tool.outputs {
let Some(oracle_value) = tool.oracle.get(&entry.json_key) else {
continue; };
matched_keys.insert(entry.json_key.as_str());
let server_value = computed.get(&entry.seed_coord).cloned();
let (abs_delta, within_tol) = compare_output(server_value.as_ref(), Some(oracle_value));
rows.push(OutputRow {
key: entry.json_key.clone(),
cell: Some(entry.seed_coord.clone()),
server_value,
oracle_value: Some(oracle_value.clone()),
abs_delta,
within_tol,
});
}
for (key, oracle_value) in &tool.oracle {
if matched_keys.contains(key.as_str()) {
continue;
}
let (abs_delta, within_tol) = compare_output(None, Some(oracle_value));
rows.push(OutputRow {
key: key.clone(),
cell: None,
server_value: None,
oracle_value: Some(oracle_value.clone()),
abs_delta,
within_tol,
});
}
let compared = u32::try_from(rows.len()).unwrap_or(u32::MAX);
let all_within_tol = rows.iter().all(|r| r.within_tol);
(
ToolReport {
tool: tool.name.clone(),
all_within_tol,
outputs: rows,
},
compared,
)
}
#[allow(clippy::result_large_err)]
pub fn reconcile_reference(
cell_map: &crate::artifact_model::CellMap,
manifest: &Manifest,
ir: &HashMap<String, Cell>,
dag: &Dag,
tol: f64,
) -> Result<ReconcileReport, Box<LintFinding>> {
let mut env = CellEnv::new();
for (key, value) in seed_reference_inputs(manifest) {
env = env.seed_cell(key, &value);
}
let run = run_executor(ir, dag, &env)?;
let mut tools = Vec::with_capacity(cell_map.tools.len());
let mut cells_checked: u32 = 0;
let mut all_within_tol = true;
for tool in &cell_map.tools {
let (report, compared) = reconcile_tool(tool, &run.computed);
cells_checked = cells_checked.saturating_add(compared);
all_within_tol = all_within_tol && report.all_within_tol;
tools.push(report);
}
Ok(ReconcileReport {
tolerance: tol,
all_within_tol,
cells_checked,
tools,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::artifact_model::{CellEntry, CellMap};
use crate::manifest_model::{CellRole, Dtype};
use crate::sheet_ir::{build_dag, Cell, CellExpr};
fn input_role(cell: &str, default: CellValue) -> CellRole {
CellRole {
cell: cell.to_string(),
role: Role::Input,
name: None,
unit: None,
meaning: None,
dtype: Dtype::Number,
colour_evidence: None,
source: "test".into(),
notes: None,
tier: Some(InputTier::Variable { default }),
allowed_values: None,
}
}
fn manifest_with(cells: Vec<CellRole>) -> Manifest {
Manifest {
schema_version: 1,
workflow: "test".into(),
workbook_hash: None,
ratified: true,
ratified_by: None,
ratified_at: None,
cells,
loop_block: None,
governed_data: vec![],
changelog: vec![],
capability_calls: vec![],
annotations: vec![],
}
}
fn output_entry(json_key: &str, seed_coord: &str) -> CellEntry {
CellEntry {
json_key: json_key.to_string(),
seed_coord: seed_coord.to_string(),
unit: None,
}
}
fn literal_cell(key: &str, value: CellValue) -> Cell {
Cell {
key: key.to_string(),
expr: CellExpr::Literal(value),
}
}
#[test]
fn seed_reference_inputs_reads_tier_defaults() {
let manifest = manifest_with(vec![
input_role("S!A1", CellValue::Number(10.0)),
input_role("S!A2", CellValue::Text("hi".into())),
]);
let seeds = seed_reference_inputs(&manifest);
assert_eq!(seeds.get("S!A1"), Some(&CellValue::Number(10.0)));
assert_eq!(seeds.get("S!A2"), Some(&CellValue::Text("hi".into())));
}
#[test]
fn seed_reference_inputs_skips_untiered_inputs() {
let mut role = input_role("S!A1", CellValue::Number(1.0));
role.tier = None;
let manifest = manifest_with(vec![role]);
let seeds = seed_reference_inputs(&manifest);
assert!(
seeds.is_empty(),
"an untiered Role::Input contributes no seed"
);
}
#[test]
fn seed_reference_inputs_skips_non_input_roles() {
let mut role = input_role("S!A1", CellValue::Number(1.0));
role.role = Role::Constant;
let manifest = manifest_with(vec![role]);
assert!(seed_reference_inputs(&manifest).is_empty());
}
fn one_output_tool(oracle: CellValue) -> (CellMap, Manifest, HashMap<String, Cell>, Dag) {
let manifest = manifest_with(vec![input_role("S!A1", CellValue::Number(5.0))]);
let mut ir = HashMap::new();
ir.insert(
"S!B1".to_string(),
literal_cell("S!B1", CellValue::Number(5.0)),
);
let dag = build_dag(&ir);
let mut oracle_map = BTreeMap::new();
oracle_map.insert("out".to_string(), oracle);
let cell_map = CellMap {
inputs: vec![],
tools: vec![Tool {
name: "T".into(),
description: None,
input_keys: vec![],
outputs: vec![output_entry("out", "S!B1")],
oracle: oracle_map,
}],
};
(cell_map, manifest, ir, dag)
}
#[test]
fn golden_within_tol_reconciles_true() {
let (cell_map, manifest, ir, dag) = one_output_tool(CellValue::Number(5.0));
let report = reconcile_reference(&cell_map, &manifest, &ir, &dag, TOL).unwrap();
assert!(report.all_within_tol);
assert_eq!(report.cells_checked, 1);
let row = &report.tools[0].outputs[0];
assert_eq!(row.cell.as_deref(), Some("S!B1"));
assert!(row.within_tol);
assert!(row.abs_delta <= TOL);
}
#[test]
fn perturbed_oracle_reconciles_false() {
let (cell_map, manifest, ir, dag) = one_output_tool(CellValue::Number(99.0));
let report = reconcile_reference(&cell_map, &manifest, &ir, &dag, TOL).unwrap();
assert!(!report.all_within_tol);
assert!(!report.tools[0].all_within_tol);
assert!(!report.tools[0].outputs[0].within_tol);
}
#[test]
fn text_abs_delta_is_deterministic() {
let equal = compare_output(
Some(&CellValue::Text("a".into())),
Some(&CellValue::Text("a".into())),
);
assert_eq!(equal, (0.0, true));
let differ = compare_output(
Some(&CellValue::Text("a".into())),
Some(&CellValue::Text("b".into())),
);
assert_eq!(differ, (1.0, false));
}
#[test]
fn bool_abs_delta_is_deterministic() {
assert_eq!(
compare_output(Some(&CellValue::Bool(true)), Some(&CellValue::Bool(true))),
(0.0, true)
);
assert_eq!(
compare_output(Some(&CellValue::Bool(true)), Some(&CellValue::Bool(false))),
(1.0, false)
);
}
#[test]
fn type_mismatch_and_missing_fail_closed() {
assert_eq!(
compare_output(
Some(&CellValue::Number(1.0)),
Some(&CellValue::Text("x".into()))
),
(1.0, false)
);
assert_eq!(
compare_output(None, Some(&CellValue::Number(1.0))),
(1.0, false)
);
assert_eq!(
compare_output(Some(&CellValue::Empty), Some(&CellValue::Number(0.0))),
(1.0, false)
);
}
#[test]
fn empty_oracle_tool_is_vacuous_d04() {
let manifest = manifest_with(vec![]);
let ir = HashMap::new();
let dag = build_dag(&ir);
let cell_map = CellMap {
inputs: vec![],
tools: vec![Tool {
name: "Empty".into(),
description: None,
input_keys: vec![],
outputs: vec![],
oracle: BTreeMap::new(),
}],
};
let report = reconcile_reference(&cell_map, &manifest, &ir, &dag, TOL).unwrap();
assert_eq!(report.tools[0].outputs.len(), 0);
assert!(report.tools[0].all_within_tol);
assert_eq!(report.cells_checked, 0);
assert!(report.all_within_tol);
}
#[test]
fn oracle_without_outputs_entry_yields_cell_none_d02() {
let manifest = manifest_with(vec![]);
let ir = HashMap::new();
let dag = build_dag(&ir);
let mut oracle = BTreeMap::new();
oracle.insert("ghost".to_string(), CellValue::Number(1.0));
let cell_map = CellMap {
inputs: vec![],
tools: vec![Tool {
name: "T".into(),
description: None,
input_keys: vec![],
outputs: vec![], oracle,
}],
};
let report = reconcile_reference(&cell_map, &manifest, &ir, &dag, TOL).unwrap();
let row = &report.tools[0].outputs[0];
assert_eq!(row.key, "ghost");
assert_eq!(row.cell, None);
assert!(!row.within_tol); }
proptest::proptest! {
#[test]
fn prop_all_within_tol_is_conjunction(oracle in -1000.0f64..1000.0) {
let (cell_map, manifest, ir, dag) = one_output_tool(CellValue::Number(oracle));
let report = reconcile_reference(&cell_map, &manifest, &ir, &dag, TOL).unwrap();
let tool_and = report.tools.iter().all(|t| t.all_within_tol);
proptest::prop_assert_eq!(report.all_within_tol, tool_and);
let row_and = report
.tools
.iter()
.flat_map(|t| t.outputs.iter())
.all(|r| r.within_tol);
proptest::prop_assert_eq!(report.all_within_tol, row_and);
}
}
}