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}