Skip to main content

pmcp_workbook_runtime/
reconcile.rs

1//! Reference-input reconciliation (WBVER-03): re-run the served executor at the
2//! workbook's REFERENCE inputs and diff each tool output against its authored
3//! `Tool.oracle` within `TOL`.
4//!
5//! This makes the compile-time penny-reconcile RUNTIME-inspectable: a
6//! [`ReconcileReport`] attests, per output cell, that the engine reproduces
7//! Excel's authored value AT THE REFERENCE INPUTS (the manifest tier defaults —
8//! VERIFIED the oracle was computed there). It is a HONEST, narrow attestation:
9//! it does NOT attest arbitrary inputs (the downloadable formula workbook, with
10//! Excel as the oracle, covers those).
11//!
12//! Purity (reader-free leaf): this module composes ONLY the executor
13//! ([`crate::sheet_ir::run`]) + the manifest/artifact model + `serde`/`schemars`.
14//! It imports NO reader (`umya`/`quick-xml`/`calamine`) and is callable WITHOUT a
15//! toolkit dependency — the runtime carries the tier defaults natively
16//! ([`crate::manifest_model::InputTier`]), so [`seed_reference_inputs`] never
17//! re-opens the source workbook nor reaches across the layering fence.
18//!
19//! Panic-freedom: every fn on the value path is TOTAL — `?`/`get`/`match`, never
20//! `unwrap`/`expect`/`panic` (the crate-level `deny`). [`compare_output`] is a
21//! total comparison over every [`CellValue`] variant (numeric, Text, Bool,
22//! Empty, Error, type-mismatch) and never yields a `NaN`/unspecified delta.
23
24use std::collections::{BTreeMap, HashMap, HashSet};
25
26use serde::{Deserialize, Serialize};
27
28use crate::artifact_model::Tool;
29use crate::dag::Dag;
30use crate::finding::LintFinding;
31use crate::manifest_model::{InputTier, Manifest, Role};
32use crate::sheet_ir::value::CellValue;
33use crate::sheet_ir::{run as run_executor, Cell, CellEnv};
34
35/// The default reconciliation tolerance (±0.01), mirroring the compiler's
36/// `reconcile::TOL` and the runtime [`crate::scalar_eval`] `TOL` so a numeric
37/// output is graded WITHIN the SAME float-boundary slack the penny-reconcile used.
38pub const TOL: f64 = 0.01;
39
40/// One reconciled output cell: the per-key diff of the engine's recomputed value
41/// against the authored oracle.
42///
43/// `cell` is the D-01 sheet-qualified A1 address (e.g. `"3_Outputs!B3"`) of the
44/// source cell, filled from the matching [`crate::artifact_model::CellEntry`]
45/// `seed_coord`. It is [`None`] (D-02) ONLY when an `oracle` key has no matching
46/// `outputs` entry (a malformed bundle) — the row still reports its deltas.
47///
48/// Derive note: `Eq` is dropped because `abs_delta` is an `f64` (the
49/// [`crate::artifact_model::Tool`] precedent).
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
51pub struct OutputRow {
52    /// The output's LLM-facing json key.
53    pub key: String,
54    /// The D-01 sheet-qualified A1 source address; [`None`] (D-02) only when an
55    /// oracle key has no matching output entry.
56    pub cell: Option<String>,
57    /// The engine's recomputed value at the reference inputs.
58    pub server_value: Option<CellValue>,
59    /// The authored oracle value (Excel's cached `<v>`).
60    pub oracle_value: Option<CellValue>,
61    /// The absolute delta: `|server − oracle|` for numbers; `0.0` (equal) or `1.0`
62    /// (not equal / type mismatch / Empty / Error) for the discrete types —
63    /// DETERMINISTIC, never `NaN`/unspecified.
64    pub abs_delta: f64,
65    /// `true` iff this output reconciles within `TOL`.
66    pub within_tol: bool,
67}
68
69/// The per-tool reconciliation report.
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
71pub struct ToolReport {
72    /// The tool name.
73    pub tool: String,
74    /// `true` iff every checked output in this tool is within `TOL`. A tool with
75    /// an empty oracle is vacuously `true` (D-04).
76    pub all_within_tol: bool,
77    /// One [`OutputRow`] per oracle/output key. Empty for an empty-oracle tool
78    /// (D-04).
79    pub outputs: Vec<OutputRow>,
80}
81
82/// The full reconciliation report — the `verify_accuracy` payload.
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
84pub struct ReconcileReport {
85    /// The tolerance the report was graded at.
86    pub tolerance: f64,
87    /// `true` iff every checked output across every reported tool is within `TOL`.
88    pub all_within_tol: bool,
89    /// The number of output rows that were actually COMPARED (an empty-oracle tool
90    /// contributes 0; D-04).
91    pub cells_checked: u32,
92    /// One [`ToolReport`] per reconciled tool.
93    pub tools: Vec<ToolReport>,
94}
95
96/// Build the REFERENCE-input seed map natively from the manifest tier defaults.
97///
98/// Iterates `manifest.cells`, keeps each [`Role::Input`], and reads its
99/// [`InputTier`] default as a runtime-native [`CellValue`] — the
100/// [`InputTier::Variable`] / [`InputTier::BoundedVariable`] `default`. An input
101/// whose `tier` is [`None`] contributes NO seed (mirroring the toolkit's
102/// `tier_default` `Some`-guard); the executor then resolves it from the IR.
103///
104/// This is a runtime-native mirror of the TOOLKIT-private `seed_tier_defaults`
105/// (which returns `serde_json::Value`) at the manifest-tier level — returning the
106/// runtime [`CellValue`] WITHOUT a toolkit dependency and WITHOUT re-implementing
107/// the toolkit's dtype/enum input validation (reconcile needs only the reference
108/// values, not input validation).
109///
110/// # Examples
111///
112/// ```
113/// use std::collections::BTreeMap;
114/// use pmcp_workbook_runtime::reconcile::seed_reference_inputs;
115/// use pmcp_workbook_runtime::{CellValue, InputTier, Manifest, Role};
116/// use pmcp_workbook_runtime::manifest_model::{CellRole, Dtype};
117///
118/// let manifest = Manifest {
119///     schema_version: 1,
120///     workflow: "demo".into(),
121///     workbook_hash: None,
122///     ratified: true,
123///     ratified_by: None,
124///     ratified_at: None,
125///     cells: vec![CellRole {
126///         cell: "1_Inputs!B2".into(),
127///         role: Role::Input,
128///         name: Some("in_x".into()),
129///         unit: None,
130///         meaning: None,
131///         dtype: Dtype::Number,
132///         colour_evidence: None,
133///         source: "test".into(),
134///         notes: None,
135///         tier: Some(InputTier::Variable { default: CellValue::Number(42.0) }),
136///         allowed_values: None,
137///     }],
138///     loop_block: None,
139///     governed_data: vec![],
140///     changelog: vec![],
141///     capability_calls: vec![],
142///     annotations: vec![],
143/// };
144///
145/// let seeds = seed_reference_inputs(&manifest);
146/// assert_eq!(seeds.get("1_Inputs!B2"), Some(&CellValue::Number(42.0)));
147/// ```
148#[must_use]
149pub fn seed_reference_inputs(manifest: &Manifest) -> BTreeMap<String, CellValue> {
150    let mut seeds = BTreeMap::new();
151    for role in &manifest.cells {
152        if !matches!(role.role, Role::Input) {
153            continue;
154        }
155        match &role.tier {
156            Some(InputTier::Variable { default })
157            | Some(InputTier::BoundedVariable { default, .. }) => {
158                seeds.insert(role.cell.clone(), default.clone());
159            },
160            None => {},
161        }
162    }
163    seeds
164}
165
166/// Compare one server value against its oracle, returning `(abs_delta, within_tol)`.
167///
168/// TOTAL over every [`CellValue`] pairing (and the missing-value cases):
169/// - numeric/numeric → `abs_delta = |server − oracle|`, `within_tol` iff BOTH are
170///   finite AND `abs_delta <= tol`;
171/// - `Text`/`Text` or `Bool`/`Bool` → equality: `0.0` + `true` when equal, `1.0` +
172///   `false` when not equal;
173/// - any other pairing (`Empty`, `Error`, a type mismatch, or a missing
174///   server/oracle value) → `1.0` + `false` (fail-closed).
175///
176/// NEVER yields a `NaN`/unspecified delta.
177#[must_use]
178fn compare_output(server: Option<&CellValue>, oracle: Option<&CellValue>) -> (f64, bool) {
179    match (server, oracle) {
180        (Some(CellValue::Number(s)), Some(CellValue::Number(o)))
181            if s.is_finite() && o.is_finite() =>
182        {
183            let delta = (s - o).abs();
184            (delta, delta <= TOL)
185        },
186        (Some(CellValue::Text(s)), Some(CellValue::Text(o))) => discrete_eq(s == o),
187        (Some(CellValue::Bool(s)), Some(CellValue::Bool(o))) => discrete_eq(s == o),
188        // Empty/Error/type-mismatch/missing → fail-closed, deterministic.
189        _ => (1.0, false),
190    }
191}
192
193/// The deterministic discrete-type delta: `(0.0, true)` when equal, `(1.0,
194/// false)` when not (Text/Bool). Never `NaN`.
195#[must_use]
196fn discrete_eq(equal: bool) -> (f64, bool) {
197    if equal {
198        (0.0, true)
199    } else {
200        (1.0, false)
201    }
202}
203
204/// Reconcile ONE tool: project each oracle/output key into an [`OutputRow`] and
205/// roll up the tool-level `all_within_tol` + the count of COMPARED rows.
206///
207/// A row is built for every `outputs` entry that has an oracle value, PLUS any
208/// oracle key with NO matching `outputs` entry (D-02: `cell = None`, still graded).
209/// An empty oracle yields `outputs: []` + `all_within_tol = true` (D-04, vacuous),
210/// contributing 0 to the comparison count.
211fn reconcile_tool(tool: &Tool, computed: &HashMap<String, CellValue>) -> (ToolReport, u32) {
212    let mut rows = Vec::new();
213    // Borrowed output keys we have already graded — used to skip oracle-only keys
214    // in the D-02 loop below. Borrowed (`&str`) + set membership avoids a per-key
215    // String clone and the O(outputs × oracle) linear scan.
216    let mut matched_keys: HashSet<&str> = HashSet::new();
217
218    // Rows for declared outputs (the common path: cell = Some(seed_coord)).
219    for entry in &tool.outputs {
220        let Some(oracle_value) = tool.oracle.get(&entry.json_key) else {
221            continue; // an output with no authored oracle is not graded here.
222        };
223        matched_keys.insert(entry.json_key.as_str());
224        let server_value = computed.get(&entry.seed_coord).cloned();
225        let (abs_delta, within_tol) = compare_output(server_value.as_ref(), Some(oracle_value));
226        rows.push(OutputRow {
227            key: entry.json_key.clone(),
228            cell: Some(entry.seed_coord.clone()),
229            server_value,
230            oracle_value: Some(oracle_value.clone()),
231            abs_delta,
232            within_tol,
233        });
234    }
235
236    // D-02: any oracle key WITHOUT a matching outputs entry → cell = None, graded.
237    for (key, oracle_value) in &tool.oracle {
238        if matched_keys.contains(key.as_str()) {
239            continue;
240        }
241        let (abs_delta, within_tol) = compare_output(None, Some(oracle_value));
242        rows.push(OutputRow {
243            key: key.clone(),
244            cell: None,
245            server_value: None,
246            oracle_value: Some(oracle_value.clone()),
247            abs_delta,
248            within_tol,
249        });
250    }
251
252    let compared = u32::try_from(rows.len()).unwrap_or(u32::MAX);
253    let all_within_tol = rows.iter().all(|r| r.within_tol);
254    (
255        ToolReport {
256            tool: tool.name.clone(),
257            all_within_tol,
258            outputs: rows,
259        },
260        compared,
261    )
262}
263
264/// Re-run the executor at the workbook's REFERENCE inputs and reconcile every
265/// tool output against its authored `Tool.oracle` within `tol`.
266///
267/// Seeds the [`CellEnv`] natively from [`seed_reference_inputs`] (the manifest
268/// tier defaults — NO toolkit dep, no serde round-trip), runs the SHARED executor
269/// ([`crate::sheet_ir::run`] — no second evaluator), then projects per tool via
270/// [`reconcile_tool`]. The report's `all_within_tol` is true iff EVERY compared
271/// output is within `tol`; `cells_checked` counts only compared rows (an
272/// empty-oracle tool contributes 0, D-04).
273///
274/// Panic-free: returns `Err(Box<LintFinding>)` on an executor failure (e.g. a DAG
275/// cycle — impossible for a conforming bundle); never `unwrap`/`panic`.
276///
277/// # Errors
278///
279/// Returns the located [`LintFinding`] the executor surfaces (e.g. a `dag/cycle`).
280///
281/// # Examples
282///
283/// ```
284/// use std::collections::HashMap;
285/// use pmcp_workbook_runtime::reconcile::reconcile_reference;
286/// use pmcp_workbook_runtime::{build_dag, CellMap, Manifest};
287///
288/// // A degenerate bundle with no tools reconciles vacuously.
289/// let manifest = Manifest {
290///     schema_version: 1,
291///     workflow: "empty".into(),
292///     workbook_hash: None,
293///     ratified: true,
294///     ratified_by: None,
295///     ratified_at: None,
296///     cells: vec![],
297///     loop_block: None,
298///     governed_data: vec![],
299///     changelog: vec![],
300///     capability_calls: vec![],
301///     annotations: vec![],
302/// };
303/// let cell_map = CellMap { inputs: vec![], tools: vec![] };
304/// let ir = HashMap::new();
305/// let dag = build_dag(&ir);
306/// let report = reconcile_reference(&cell_map, &manifest, &ir, &dag, 0.01).unwrap();
307/// assert!(report.all_within_tol);
308/// assert_eq!(report.cells_checked, 0);
309/// ```
310#[allow(clippy::result_large_err)]
311pub fn reconcile_reference(
312    cell_map: &crate::artifact_model::CellMap,
313    manifest: &Manifest,
314    ir: &HashMap<String, Cell>,
315    dag: &Dag,
316    tol: f64,
317) -> Result<ReconcileReport, Box<LintFinding>> {
318    // Seed the executor natively from the manifest reference (tier) defaults.
319    let mut env = CellEnv::new();
320    for (key, value) in seed_reference_inputs(manifest) {
321        env = env.seed_cell(key, &value);
322    }
323
324    let run = run_executor(ir, dag, &env)?;
325
326    let mut tools = Vec::with_capacity(cell_map.tools.len());
327    let mut cells_checked: u32 = 0;
328    let mut all_within_tol = true;
329    for tool in &cell_map.tools {
330        let (report, compared) = reconcile_tool(tool, &run.computed);
331        cells_checked = cells_checked.saturating_add(compared);
332        all_within_tol = all_within_tol && report.all_within_tol;
333        tools.push(report);
334    }
335
336    Ok(ReconcileReport {
337        tolerance: tol,
338        all_within_tol,
339        cells_checked,
340        tools,
341    })
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use crate::artifact_model::{CellEntry, CellMap};
348    use crate::manifest_model::{CellRole, Dtype};
349    use crate::sheet_ir::{build_dag, Cell, CellExpr};
350
351    fn input_role(cell: &str, default: CellValue) -> CellRole {
352        CellRole {
353            cell: cell.to_string(),
354            role: Role::Input,
355            name: None,
356            unit: None,
357            meaning: None,
358            dtype: Dtype::Number,
359            colour_evidence: None,
360            source: "test".into(),
361            notes: None,
362            tier: Some(InputTier::Variable { default }),
363            allowed_values: None,
364        }
365    }
366
367    fn manifest_with(cells: Vec<CellRole>) -> Manifest {
368        Manifest {
369            schema_version: 1,
370            workflow: "test".into(),
371            workbook_hash: None,
372            ratified: true,
373            ratified_by: None,
374            ratified_at: None,
375            cells,
376            loop_block: None,
377            governed_data: vec![],
378            changelog: vec![],
379            capability_calls: vec![],
380            annotations: vec![],
381        }
382    }
383
384    fn output_entry(json_key: &str, seed_coord: &str) -> CellEntry {
385        CellEntry {
386            json_key: json_key.to_string(),
387            seed_coord: seed_coord.to_string(),
388            unit: None,
389        }
390    }
391
392    /// A literal cell that the executor will echo into `run.computed`.
393    fn literal_cell(key: &str, value: CellValue) -> Cell {
394        Cell {
395            key: key.to_string(),
396            expr: CellExpr::Literal(value),
397        }
398    }
399
400    #[test]
401    fn seed_reference_inputs_reads_tier_defaults() {
402        let manifest = manifest_with(vec![
403            input_role("S!A1", CellValue::Number(10.0)),
404            input_role("S!A2", CellValue::Text("hi".into())),
405        ]);
406        let seeds = seed_reference_inputs(&manifest);
407        assert_eq!(seeds.get("S!A1"), Some(&CellValue::Number(10.0)));
408        assert_eq!(seeds.get("S!A2"), Some(&CellValue::Text("hi".into())));
409    }
410
411    #[test]
412    fn seed_reference_inputs_skips_untiered_inputs() {
413        let mut role = input_role("S!A1", CellValue::Number(1.0));
414        role.tier = None;
415        let manifest = manifest_with(vec![role]);
416        let seeds = seed_reference_inputs(&manifest);
417        assert!(
418            seeds.is_empty(),
419            "an untiered Role::Input contributes no seed"
420        );
421    }
422
423    #[test]
424    fn seed_reference_inputs_skips_non_input_roles() {
425        let mut role = input_role("S!A1", CellValue::Number(1.0));
426        role.role = Role::Constant;
427        let manifest = manifest_with(vec![role]);
428        assert!(seed_reference_inputs(&manifest).is_empty());
429    }
430
431    /// A one-output bundle whose oracle matches the recomputed reference value
432    /// reconciles `all_within_tol == true`, `cells_checked == 1`, `cell == coord`.
433    fn one_output_tool(oracle: CellValue) -> (CellMap, Manifest, HashMap<String, Cell>, Dag) {
434        let manifest = manifest_with(vec![input_role("S!A1", CellValue::Number(5.0))]);
435        let mut ir = HashMap::new();
436        // The output cell is a literal so the executor echoes a known value.
437        ir.insert(
438            "S!B1".to_string(),
439            literal_cell("S!B1", CellValue::Number(5.0)),
440        );
441        let dag = build_dag(&ir);
442        let mut oracle_map = BTreeMap::new();
443        oracle_map.insert("out".to_string(), oracle);
444        let cell_map = CellMap {
445            inputs: vec![],
446            tools: vec![Tool {
447                name: "T".into(),
448                description: None,
449                input_keys: vec![],
450                outputs: vec![output_entry("out", "S!B1")],
451                oracle: oracle_map,
452            }],
453        };
454        (cell_map, manifest, ir, dag)
455    }
456
457    #[test]
458    fn golden_within_tol_reconciles_true() {
459        let (cell_map, manifest, ir, dag) = one_output_tool(CellValue::Number(5.0));
460        let report = reconcile_reference(&cell_map, &manifest, &ir, &dag, TOL).unwrap();
461        assert!(report.all_within_tol);
462        assert_eq!(report.cells_checked, 1);
463        let row = &report.tools[0].outputs[0];
464        assert_eq!(row.cell.as_deref(), Some("S!B1"));
465        assert!(row.within_tol);
466        assert!(row.abs_delta <= TOL);
467    }
468
469    #[test]
470    fn perturbed_oracle_reconciles_false() {
471        // Oracle deliberately wrong (5.0 computed, oracle 99.0).
472        let (cell_map, manifest, ir, dag) = one_output_tool(CellValue::Number(99.0));
473        let report = reconcile_reference(&cell_map, &manifest, &ir, &dag, TOL).unwrap();
474        assert!(!report.all_within_tol);
475        assert!(!report.tools[0].all_within_tol);
476        assert!(!report.tools[0].outputs[0].within_tol);
477    }
478
479    #[test]
480    fn text_abs_delta_is_deterministic() {
481        let equal = compare_output(
482            Some(&CellValue::Text("a".into())),
483            Some(&CellValue::Text("a".into())),
484        );
485        assert_eq!(equal, (0.0, true));
486        let differ = compare_output(
487            Some(&CellValue::Text("a".into())),
488            Some(&CellValue::Text("b".into())),
489        );
490        assert_eq!(differ, (1.0, false));
491    }
492
493    #[test]
494    fn bool_abs_delta_is_deterministic() {
495        assert_eq!(
496            compare_output(Some(&CellValue::Bool(true)), Some(&CellValue::Bool(true))),
497            (0.0, true)
498        );
499        assert_eq!(
500            compare_output(Some(&CellValue::Bool(true)), Some(&CellValue::Bool(false))),
501            (1.0, false)
502        );
503    }
504
505    #[test]
506    fn type_mismatch_and_missing_fail_closed() {
507        // Number vs Text → fail-closed.
508        assert_eq!(
509            compare_output(
510                Some(&CellValue::Number(1.0)),
511                Some(&CellValue::Text("x".into()))
512            ),
513            (1.0, false)
514        );
515        // Missing server value → fail-closed.
516        assert_eq!(
517            compare_output(None, Some(&CellValue::Number(1.0))),
518            (1.0, false)
519        );
520        // Empty → fail-closed.
521        assert_eq!(
522            compare_output(Some(&CellValue::Empty), Some(&CellValue::Number(0.0))),
523            (1.0, false)
524        );
525    }
526
527    #[test]
528    fn empty_oracle_tool_is_vacuous_d04() {
529        let manifest = manifest_with(vec![]);
530        let ir = HashMap::new();
531        let dag = build_dag(&ir);
532        let cell_map = CellMap {
533            inputs: vec![],
534            tools: vec![Tool {
535                name: "Empty".into(),
536                description: None,
537                input_keys: vec![],
538                outputs: vec![],
539                oracle: BTreeMap::new(),
540            }],
541        };
542        let report = reconcile_reference(&cell_map, &manifest, &ir, &dag, TOL).unwrap();
543        assert_eq!(report.tools[0].outputs.len(), 0);
544        assert!(report.tools[0].all_within_tol);
545        assert_eq!(report.cells_checked, 0);
546        assert!(report.all_within_tol);
547    }
548
549    #[test]
550    fn oracle_without_outputs_entry_yields_cell_none_d02() {
551        let manifest = manifest_with(vec![]);
552        let ir = HashMap::new();
553        let dag = build_dag(&ir);
554        let mut oracle = BTreeMap::new();
555        oracle.insert("ghost".to_string(), CellValue::Number(1.0));
556        let cell_map = CellMap {
557            inputs: vec![],
558            tools: vec![Tool {
559                name: "T".into(),
560                description: None,
561                input_keys: vec![],
562                outputs: vec![], // no matching entry for "ghost"
563                oracle,
564            }],
565        };
566        let report = reconcile_reference(&cell_map, &manifest, &ir, &dag, TOL).unwrap();
567        let row = &report.tools[0].outputs[0];
568        assert_eq!(row.key, "ghost");
569        assert_eq!(row.cell, None);
570        assert!(!row.within_tol); // no server value → fail-closed
571    }
572
573    proptest::proptest! {
574        /// Report-level all_within_tol == AND over tool-level, and holds iff every
575        /// OutputRow.within_tol is true.
576        #[test]
577        fn prop_all_within_tol_is_conjunction(oracle in -1000.0f64..1000.0) {
578            let (cell_map, manifest, ir, dag) = one_output_tool(CellValue::Number(oracle));
579            let report = reconcile_reference(&cell_map, &manifest, &ir, &dag, TOL).unwrap();
580            let tool_and = report.tools.iter().all(|t| t.all_within_tol);
581            proptest::prop_assert_eq!(report.all_within_tol, tool_and);
582            let row_and = report
583                .tools
584                .iter()
585                .flat_map(|t| t.outputs.iter())
586                .all(|r| r.within_tol);
587            proptest::prop_assert_eq!(report.all_within_tol, row_and);
588        }
589    }
590}