1#![cfg_attr(
20 not(test),
21 deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)
22)]
23
24use std::collections::BTreeMap;
25use std::sync::Arc;
26
27use async_trait::async_trait;
28use pmcp::types::ToolInfo;
29use pmcp::{RequestHandlerExtra, ToolHandler};
30use serde_json::{json, Value};
31
32use pmcp_workbook_runtime::{run_executor, CellEnv, CellValue, RenderMode, RunResult, Tool};
33
34use super::error::{to_iserror_result, WorkbookToolError};
35use super::input::validate_input;
36use super::render_uri;
37use super::schema::{
38 diff_version_output_schema, empty_input_schema, explain_output_schema,
39 get_manifest_output_schema, input_schema_for_manifest, input_schema_for_tool,
40 output_schema_for_tool, render_input_schema_for_manifest, render_workbook_output_schema,
41 verify_accuracy_input_schema, verify_accuracy_output_schema,
42};
43use super::{ProvStamp, WorkbookBundle, WORKBOOK_TOOL_UI};
44
45#[allow(clippy::result_large_err)]
53pub(crate) fn run_bundle(
54 bundle: &WorkbookBundle,
55 seeds: BTreeMap<String, Value>,
56) -> Result<RunResult, WorkbookToolError> {
57 let mut env = CellEnv::new();
58 for (key, value) in seeds {
59 env = env.with_value(key, value);
60 }
61 run_executor(&bundle.ir, &bundle.dag, &env).map_err(|f| {
62 WorkbookToolError::invalid_input(format!("executor failed: {} ({})", f.message, f.rule))
63 })
64}
65
66#[allow(clippy::result_large_err)]
73pub(crate) fn project_tool_outputs(
74 tool: &Tool,
75 run: &RunResult,
76) -> Result<Value, WorkbookToolError> {
77 let mut outputs = serde_json::Map::new();
78 for entry in &tool.outputs {
79 let Some(value) = run.computed.get(&entry.seed_coord) else {
80 return Err(WorkbookToolError::invalid_input(format!(
81 "internal: declared output '{}' ({}) was not computed by the bundle IR",
82 entry.json_key, entry.seed_coord
83 )));
84 };
85 let projected = finite_output_value(value, &entry.seed_coord, &entry.json_key)?;
86 outputs.insert(
87 entry.json_key.clone(),
88 json!({ "value": projected, "unit": entry.unit }),
89 );
90 }
91 Ok(Value::Object(outputs))
92}
93
94#[allow(clippy::result_large_err)]
97fn finite_output_value(
98 value: &CellValue,
99 seed_coord: &str,
100 json_key: &str,
101) -> Result<Value, WorkbookToolError> {
102 match value {
103 CellValue::Number(n) if n.is_finite() => Ok(json!(n)),
104 CellValue::Number(_) => Err(WorkbookToolError::invalid_input(format!(
105 "output cell {seed_coord} ({json_key}) did not compute to a finite number"
106 ))),
107 CellValue::Text(s) => Ok(json!(s)),
108 CellValue::Bool(b) => Ok(json!(b)),
109 CellValue::Empty => Ok(Value::Null),
110 CellValue::Error(e) => Err(WorkbookToolError::invalid_input(format!(
111 "output cell {seed_coord} ({json_key}) computed to an error: {e:?}"
112 ))),
113 }
114}
115
116pub(crate) fn with_provenance(mut payload: Value, stamp: &ProvStamp) -> Value {
118 if let Some(obj) = payload.as_object_mut() {
119 obj.insert("provenance".to_string(), stamp.to_json());
120 }
121 payload
122}
123
124#[allow(clippy::result_large_err)]
128pub(crate) fn render_at_boundary(
129 result: Result<Value, WorkbookToolError>,
130 stamp: &ProvStamp,
131) -> Value {
132 result.unwrap_or_else(|e| to_iserror_result(&e, stamp))
133}
134
135#[allow(clippy::result_large_err)]
149pub fn sanitize_tool_name(raw: &str) -> Result<String, WorkbookToolError> {
150 pmcp_workbook_runtime::sanitize_tool_name(raw).map_err(WorkbookToolError::unmappable_tool_name)
151}
152
153pub struct WorkbookToolHandler {
161 bundle: Arc<WorkbookBundle>,
162 tool: Tool,
163 stamp: ProvStamp,
164}
165
166impl WorkbookToolHandler {
167 #[must_use]
169 pub fn new(bundle: Arc<WorkbookBundle>, tool: Tool) -> Self {
170 let stamp = ProvStamp::from_bundle(&bundle);
171 Self {
172 bundle,
173 tool,
174 stamp,
175 }
176 }
177
178 #[allow(clippy::result_large_err)]
184 pub fn registered_name(&self) -> Result<String, WorkbookToolError> {
185 sanitize_tool_name(&self.tool.name)
186 }
187
188 fn description(&self) -> String {
191 self.tool.description.clone().unwrap_or_else(|| {
192 format!(
193 "Compute the '{}' workbook outputs from the declared inputs by re-running \
194 the compiled workbook IR. Returns each output as a units-bearing \
195 {{ value, unit }} projection plus a provenance stamp. Strict \
196 (BA-governed) constants cannot be overridden.",
197 self.tool.name
198 )
199 })
200 }
201
202 #[allow(clippy::result_large_err)]
205 fn compute(&self, args: Value) -> Result<Value, WorkbookToolError> {
206 let validated = validate_input(args, &self.bundle.manifest, &self.bundle.cell_map)?;
207 let run = run_bundle(&self.bundle, validated.seeds)?;
208 let outputs = project_tool_outputs(&self.tool, &run)?;
209 let payload = json!({
210 "outputs": outputs,
211 "accepted_overrides": validated.accepted_overrides,
212 });
213 Ok(with_provenance(payload, &self.stamp))
214 }
215}
216
217#[async_trait]
218impl ToolHandler for WorkbookToolHandler {
219 async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> {
220 Ok(render_at_boundary(self.compute(args), &self.stamp))
221 }
222
223 fn metadata(&self) -> Option<ToolInfo> {
224 let name = self
228 .registered_name()
229 .unwrap_or_else(|_| self.tool.name.clone());
230 Some(
231 ToolInfo::with_ui(
232 name,
233 Some(self.description()),
234 input_schema_for_tool(&self.bundle.manifest, &self.bundle.cell_map, &self.tool),
235 WORKBOOK_TOOL_UI,
236 )
237 .with_output_schema(output_schema_for_tool(&self.bundle.manifest, &self.tool)),
238 )
239 }
240}
241
242fn cell_value_display(v: &CellValue) -> Value {
246 match v {
247 CellValue::Number(n) => json!(n),
248 CellValue::Text(s) => json!(s),
249 CellValue::Bool(b) => json!(b),
250 CellValue::Empty => Value::Null,
251 CellValue::Error(e) => json!(format!("{e:?}")),
252 }
253}
254
255pub struct ExplainHandler {
261 bundle: Arc<WorkbookBundle>,
262 stamp: ProvStamp,
263}
264
265impl ExplainHandler {
266 pub const NAME: &str = "explain";
268
269 #[must_use]
271 pub fn new(bundle: Arc<WorkbookBundle>) -> Self {
272 let stamp = ProvStamp::from_bundle(&bundle);
273 Self { bundle, stamp }
274 }
275
276 #[allow(clippy::result_large_err)]
279 fn compute(&self, args: Value) -> Result<Value, WorkbookToolError> {
280 let validated = validate_input(args, &self.bundle.manifest, &self.bundle.cell_map)?;
281 let run = run_bundle(&self.bundle, validated.seeds)?;
282 let steps = self.render_steps(&run);
283 let payload = json!({
284 "steps": steps,
285 "annotations": self.manifest_annotations(),
286 });
287 Ok(with_provenance(payload, &self.stamp))
288 }
289
290 fn render_steps(&self, run: &RunResult) -> Vec<Value> {
294 let mut entries: Vec<_> = run.traces.iter().collect();
295 entries.sort_by(|a, b| a.0.cmp(b.0));
296 let mut steps = Vec::with_capacity(entries.len());
297 for (key, trace) in entries {
298 steps.push(json!({
299 "step": "derivation",
300 "cell": key,
301 "meaning": self.meaning_for(key),
302 "formula": trace.formula,
303 "dispatched_fn": trace.dispatched_fn,
304 "resolved_refs": trace.resolved_refs.iter().map(|(k, v)| json!({
305 "cell": k,
306 "value": cell_value_display(v),
307 })).collect::<Vec<_>>(),
308 "result": run.computed.get(key).map(cell_value_display),
309 }));
310 }
311 steps
312 }
313
314 fn manifest_annotations(&self) -> Value {
319 let mut obj = serde_json::Map::new();
320 for ann in &self.bundle.manifest.annotations {
321 obj.insert(
322 ann.name.clone(),
323 json!({ "target": ann.target, "meaning": ann.meaning }),
324 );
325 }
326 Value::Object(obj)
327 }
328
329 fn meaning_for(&self, key: &str) -> Option<String> {
331 pmcp_workbook_runtime::role_for_cell(&self.bundle.manifest, key)
332 .and_then(|c| c.meaning.clone().or_else(|| c.name.clone()))
333 }
334}
335
336#[async_trait]
337impl ToolHandler for ExplainHandler {
338 async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> {
339 Ok(render_at_boundary(self.compute(args), &self.stamp))
340 }
341
342 fn metadata(&self) -> Option<ToolInfo> {
343 Some(
344 ToolInfo::with_ui(
345 Self::NAME,
346 Some(
347 "Explain the computed workbook outputs: an ordered business-language \
348 derivation trace (formula + operands + meaning per step) plus a \
349 manifest-declared annotations object. Stamped + stateless (re-run \
350 from the same inputs)."
351 .into(),
352 ),
353 input_schema_for_manifest(&self.bundle.manifest, &self.bundle.cell_map),
354 WORKBOOK_TOOL_UI,
355 )
356 .with_output_schema(explain_output_schema()),
357 )
358 }
359}
360
361pub struct GetManifestHandler {
367 bundle: Arc<WorkbookBundle>,
368 stamp: ProvStamp,
369}
370
371impl GetManifestHandler {
372 pub const NAME: &str = "get_manifest";
374
375 #[must_use]
377 pub fn new(bundle: Arc<WorkbookBundle>) -> Self {
378 let stamp = ProvStamp::from_bundle(&bundle);
379 Self { bundle, stamp }
380 }
381}
382
383fn input_projection(role: &pmcp_workbook_runtime::CellRole) -> Value {
392 use pmcp_workbook_runtime::{json_key_for_role, InputTier};
393 let (tier_kind, default) = match &role.tier {
394 Some(InputTier::Variable { default }) => ("variable", cell_value_display(default)),
395 Some(InputTier::BoundedVariable { default, .. }) => {
396 ("bounded_variable", cell_value_display(default))
397 },
398 None => ("variable", Value::Null),
399 };
400 json!({
401 "name": json_key_for_role(role),
402 "governance_name": role.name,
403 "unit": role.unit,
404 "meaning": role.meaning,
405 "tier": tier_kind,
406 "default": default,
407 })
408}
409
410fn curated_manifest(bundle: &WorkbookBundle, stamp: &ProvStamp) -> Value {
416 use pmcp_workbook_runtime::{json_key_for_role, Role};
417
418 let mut inputs = Vec::new();
419 let mut outputs = Vec::new();
420 for role in &bundle.manifest.cells {
421 match role.role {
422 Role::Input => inputs.push(input_projection(role)),
423 Role::Output => outputs.push(json!({
424 "name": json_key_for_role(role),
425 "governance_name": role.name,
426 "unit": role.unit,
427 "meaning": role.meaning,
428 })),
429 Role::Constant | Role::Formula => {},
430 }
431 }
432
433 let governed: Vec<Value> = bundle
434 .manifest
435 .governed_data
436 .iter()
437 .map(|g| {
438 json!({
439 "key": g.key,
440 "value": cell_value_display(&g.value),
441 "approved_by": g.approved_by,
442 "provenance": g.provenance,
443 })
444 })
445 .collect();
446
447 let changelog: Vec<Value> = bundle
448 .manifest
449 .changelog
450 .iter()
451 .map(|c| json!({ "version": c.version, "note": c.note }))
452 .collect();
453
454 json!({
455 "bundle_id": stamp.bundle_id,
456 "version": stamp.version,
457 "combined_hash": stamp.combined_hash,
458 "inputs": inputs,
459 "outputs": outputs,
460 "governed_data": governed,
461 "changelog": changelog,
462 "provenance": stamp.to_json(),
463 })
464}
465
466#[async_trait]
467impl ToolHandler for GetManifestHandler {
468 async fn handle(&self, _args: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> {
469 Ok(curated_manifest(&self.bundle, &self.stamp))
470 }
471
472 fn metadata(&self) -> Option<ToolInfo> {
473 Some(
474 ToolInfo::with_ui(
475 Self::NAME,
476 Some(
477 "Describe the compiled workbook workflow: a curated agent-facing \
478 manifest projection (inputs with tier/default/unit, outputs with \
479 unit/meaning, governed-data summary, version/hashes, changelog) + \
480 provenance stamp."
481 .into(),
482 ),
483 empty_input_schema(),
484 WORKBOOK_TOOL_UI,
485 )
486 .with_output_schema(get_manifest_output_schema()),
487 )
488 }
489}
490
491pub struct DiffVersionHandler {
497 bundle: Arc<WorkbookBundle>,
498 stamp: ProvStamp,
499}
500
501impl DiffVersionHandler {
502 pub const NAME: &str = "diff_version";
504
505 #[must_use]
507 pub fn new(bundle: Arc<WorkbookBundle>) -> Self {
508 let stamp = ProvStamp::from_bundle(&bundle);
509 Self { bundle, stamp }
510 }
511}
512
513fn serve_changelog(bundle: &WorkbookBundle, stamp: &ProvStamp) -> Value {
517 let cl = &bundle.changelog;
518 let deltas: Vec<Value> = cl.deltas.iter().map(delta_to_json).collect();
519 let payload = json!({
520 "from_version": cl.from_version,
521 "to_version": cl.to_version,
522 "deltas": deltas,
523 "summary": cl.summary,
524 });
525 with_provenance(payload, stamp)
526}
527
528fn delta_to_json(delta: &pmcp_workbook_runtime::OutputDelta) -> Value {
530 json!({
531 "region": delta.region,
532 "change_class": delta.change_class,
533 "old": meta_to_json(&delta.old),
534 "new": meta_to_json(&delta.new),
535 "severity": delta.severity,
536 })
537}
538
539fn meta_to_json(meta: &pmcp_workbook_runtime::OutputMeta) -> Value {
541 json!({
542 "meaning": meta.meaning,
543 "unit": meta.unit,
544 "provenance": meta.provenance,
545 })
546}
547
548#[async_trait]
549impl ToolHandler for DiffVersionHandler {
550 async fn handle(&self, _args: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> {
551 Ok(serve_changelog(&self.bundle, &self.stamp))
552 }
553
554 fn metadata(&self) -> Option<ToolInfo> {
555 Some(
556 ToolInfo::with_ui(
557 Self::NAME,
558 Some(
559 "Describe what changed between two promoted workflow versions: the \
560 RECORDED, hash-verified prev→current changelog (per-output deltas \
561 with change class + drift/redefinition severity + a human-readable \
562 summary) + a provenance stamp. Served from the bundle's recorded \
563 evidence, not a runtime computation."
564 .into(),
565 ),
566 empty_input_schema(),
567 WORKBOOK_TOOL_UI,
568 )
569 .with_output_schema(diff_version_output_schema()),
570 )
571 }
572}
573
574pub struct RenderWorkbookHandler {
586 bundle: Arc<WorkbookBundle>,
587 stamp: ProvStamp,
588}
589
590impl RenderWorkbookHandler {
591 pub const NAME: &str = "render_workbook";
593
594 #[must_use]
596 pub fn new(bundle: Arc<WorkbookBundle>) -> Self {
597 let stamp = ProvStamp::from_bundle(&bundle);
598 Self { bundle, stamp }
599 }
600
601 #[allow(clippy::result_large_err)]
606 fn compute(&self, mut args: Value) -> Result<Value, WorkbookToolError> {
607 let mode = parse_render_mode(&mut args)?;
611 let validated = validate_input(args, &self.bundle.manifest, &self.bundle.cell_map)?;
612 let uri = render_uri::encode(&validated.canonical_dto, &self.stamp, mode)?;
613 let payload = json!({
614 "resource_uri": uri,
615 "mime_type": render_uri::WORKBOOK_XLSX_MIME,
616 });
617 Ok(with_provenance(payload, &self.stamp))
618 }
619}
620
621#[allow(clippy::result_large_err)]
629fn parse_render_mode(args: &mut Value) -> Result<RenderMode, WorkbookToolError> {
630 let Some(obj) = args.as_object_mut() else {
631 return Ok(RenderMode::Filled);
633 };
634 let Some(raw) = obj.remove("mode") else {
635 return Ok(RenderMode::Filled); };
637 match raw {
638 Value::Null => Ok(RenderMode::Filled),
639 Value::String(s) if s == "filled" => Ok(RenderMode::Filled),
640 Value::String(s) if s == "inputs_only" => Ok(RenderMode::InputsOnly),
641 other => Err(WorkbookToolError::invalid_input(format!(
642 "unknown render mode {other}; expected \"filled\" or \"inputs_only\""
643 ))),
644 }
645}
646
647#[async_trait]
648impl ToolHandler for RenderWorkbookHandler {
649 async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> {
650 Ok(render_at_boundary(self.compute(args), &self.stamp))
651 }
652
653 fn metadata(&self) -> Option<ToolInfo> {
654 Some(
655 ToolInfo::with_ui(
656 Self::NAME,
657 Some(
658 "Render the computed workbook to a downloadable .xlsx. Returns a \
659 provenance-bound workbook:// resource URI (NOT the bytes) — read \
660 that URI via resources/read to obtain the base64-encoded .xlsx, \
661 which is regenerated statelessly from the URI on each read. The URI \
662 encodes the inputs; treat it as sensitive."
663 .into(),
664 ),
665 render_input_schema_for_manifest(&self.bundle.manifest, &self.bundle.cell_map),
666 WORKBOOK_TOOL_UI,
667 )
668 .with_output_schema(render_workbook_output_schema()),
669 )
670 }
671}
672
673pub struct VerifyAccuracyHandler {
692 bundle: Arc<WorkbookBundle>,
693 stamp: ProvStamp,
694}
695
696impl VerifyAccuracyHandler {
697 pub const NAME: &str = "verify_accuracy";
700
701 #[must_use]
703 pub fn new(bundle: Arc<WorkbookBundle>) -> Self {
704 let stamp = ProvStamp::from_bundle(&bundle);
705 Self { bundle, stamp }
706 }
707
708 #[allow(clippy::result_large_err)]
712 fn compute(&self, args: Value) -> Result<Value, WorkbookToolError> {
713 let filter = parse_tool_filter(&args)?;
714 if let Some(name) = filter.as_deref() {
715 self.ensure_known_tool(name)?;
716 }
717
718 let report = pmcp_workbook_runtime::reconcile_reference(
719 &self.bundle.cell_map,
720 &self.bundle.manifest,
721 &self.bundle.ir,
722 &self.bundle.dag,
723 pmcp_workbook_runtime::reconcile::TOL,
724 )
725 .map_err(|f| {
726 WorkbookToolError::invalid_input(format!(
727 "reconcile failed: {} ({})",
728 f.message, f.rule
729 ))
730 })?;
731
732 let scoped = scope_report(report, filter.as_deref());
733 let payload = serde_json::to_value(&scoped).map_err(|e| {
734 WorkbookToolError::invalid_input(format!("internal: report not serializable: {e}"))
735 })?;
736 Ok(with_provenance(payload, &self.stamp))
737 }
738
739 #[allow(clippy::result_large_err)]
743 fn ensure_known_tool(&self, name: &str) -> Result<(), WorkbookToolError> {
744 if self.bundle.cell_map.tools.iter().any(|t| t.name == name) {
745 return Ok(());
746 }
747 let available: Vec<String> = self
748 .bundle
749 .cell_map
750 .tools
751 .iter()
752 .map(|t| t.name.clone())
753 .collect();
754 Err(WorkbookToolError::invalid_enum(
755 "tool",
756 available,
757 format!("unknown tool '{name}'; verify_accuracy accepts only a registered tool name"),
758 ))
759 }
760}
761
762#[allow(clippy::result_large_err)]
769fn parse_tool_filter(args: &Value) -> Result<Option<String>, WorkbookToolError> {
770 let Some(obj) = args.as_object() else {
771 return Ok(None); };
773 match obj.get("tool") {
774 None | Some(Value::Null) => Ok(None),
775 Some(Value::String(s)) => Ok(Some(s.clone())),
776 Some(other) => Err(WorkbookToolError::invalid_input(format!(
777 "the 'tool' filter must be a string tool name, got {other}"
778 ))),
779 }
780}
781
782fn scope_report(
788 report: pmcp_workbook_runtime::ReconcileReport,
789 filter: Option<&str>,
790) -> pmcp_workbook_runtime::ReconcileReport {
791 let Some(name) = filter else {
792 return report;
793 };
794 let tools: Vec<_> = report
795 .tools
796 .into_iter()
797 .filter(|t| t.tool == name)
798 .collect();
799 let cells_checked = tools
800 .iter()
801 .map(|t| u32::try_from(t.outputs.len()).unwrap_or(u32::MAX))
802 .fold(0u32, u32::saturating_add);
803 let all_within_tol = tools.iter().all(|t| t.all_within_tol);
804 pmcp_workbook_runtime::ReconcileReport {
805 tolerance: report.tolerance,
806 all_within_tol,
807 cells_checked,
808 tools,
809 }
810}
811
812#[async_trait]
813impl ToolHandler for VerifyAccuracyHandler {
814 async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> {
815 Ok(render_at_boundary(self.compute(args), &self.stamp))
816 }
817
818 fn metadata(&self) -> Option<ToolInfo> {
819 Some(
820 ToolInfo::with_ui(
821 Self::NAME,
822 Some(
823 "Verify the served engine reproduces the workbook's authored \
824 (Excel-cached) output values AT THE REFERENCE INPUTS — the \
825 compile-time penny-reconcile, made runtime-inspectable. Returns a \
826 per-output report (server value vs authored oracle, abs delta, \
827 within-tolerance) plus rollup flags, stamped + stateless. This \
828 attests ONLY the reference point; for arbitrary inputs download the \
829 formula workbook via render_workbook (filled / inputs_only) where \
830 Excel is the oracle. Optional 'tool' filter scopes the report to one \
831 tool (an unknown name returns an error listing the available tools)."
832 .into(),
833 ),
834 verify_accuracy_input_schema(),
835 WORKBOOK_TOOL_UI,
836 )
837 .with_output_schema(verify_accuracy_output_schema()),
838 )
839 }
840}
841
842#[cfg(test)]
843mod tests {
844 use super::*;
845 use std::path::{Path, PathBuf};
846
847 use pmcp_workbook_runtime::{load_bundle, LocalDirSource};
848
849 fn golden_dir() -> PathBuf {
850 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/tax-calc@1.1.0")
851 }
852
853 fn golden_bundle() -> Arc<WorkbookBundle> {
854 let source = LocalDirSource::new(golden_dir());
855 Arc::new(load_bundle(&source).expect("golden bundle boots"))
856 }
857
858 fn calc_handler() -> WorkbookToolHandler {
863 let bundle = golden_bundle();
864 let tool = bundle.cell_map.tools[0].clone();
865 WorkbookToolHandler::new(bundle, tool)
866 }
867
868 #[test]
875 fn reserved_tool_names_match_the_registered_meta_tool_names() {
876 let registered = [
877 ExplainHandler::NAME,
878 GetManifestHandler::NAME,
879 DiffVersionHandler::NAME,
880 RenderWorkbookHandler::NAME,
881 VerifyAccuracyHandler::NAME,
886 ];
887 assert_eq!(
888 pmcp_workbook_runtime::RESERVED_TOOL_NAMES,
889 registered,
890 "the shared RESERVED_TOOL_NAMES const must equal the registered meta tool \
891 NAME constants (H3 — derive, never hand-copy)"
892 );
893 }
894
895 #[test]
896 fn calculate_returns_tool_outputs_with_provenance_no_headline() {
897 let handler = calc_handler();
898 let v = handler
899 .compute(json!({ "inputs": { "gross_income": 60000.0, "filing_status": "single" } }))
900 .expect("calculate succeeds");
901
902 let outputs = v["outputs"].as_object().expect("outputs is an object");
907 assert!(!outputs.is_empty(), "the tool projects its outputs");
908 for (_key, col) in outputs {
909 let val = &col["value"];
910 assert!(
911 val.is_number() || val.is_string() || val.is_boolean() || val.is_null(),
912 "each output carries a value (number/text/bool/null)"
913 );
914 }
915 assert_eq!(
918 outputs["bracket_label"]["value"],
919 json!("bracket_2"),
920 "the text formula output computes its authored oracle"
921 );
922 assert_eq!(
923 outputs["is_taxable"]["value"],
924 json!(true),
925 "the bool formula output computes its authored oracle"
926 );
927 let root = v.as_object().expect("payload is an object");
931 let mut top_keys: Vec<&str> = root.keys().map(String::as_str).collect();
932 top_keys.sort_unstable();
933 assert_eq!(
934 top_keys,
935 ["accepted_overrides", "outputs", "provenance"],
936 "no headline field elevated above the all-outputs projection (S-1)"
937 );
938 assert!(v["provenance"]["combined_hash"].is_string());
940 assert!(v.get("isError").is_none(), "a success is not an error");
941 }
942
943 #[test]
944 fn calculate_honors_non_default_input() {
945 let handler = calc_handler();
949
950 let v = handler
952 .compute(json!({ "inputs": { "gross_income": 100000.0 } }))
953 .expect("calculate honors a non-default gross_income");
954 assert_eq!(
955 v["outputs"]["taxable_income"]["value"],
956 json!(88000.0),
957 "taxable_income reflects the caller's gross_income (100000 - 12000), not the default"
958 );
959
960 let v = handler
963 .compute(json!({ "inputs": { "gross_income": 80000.0 } }))
964 .expect("calculate honors a second non-default gross_income");
965 assert_eq!(
966 v["outputs"]["taxable_income"]["value"],
967 json!(68000.0),
968 "a second caller input flows through (80000 - 12000)"
969 );
970 }
971
972 #[test]
973 fn calculate_invalid_input_returns_iserror_in_structured_content() {
974 let bundle = golden_bundle();
975 let tool = bundle.cell_map.tools[0].clone();
976 let handler = WorkbookToolHandler::new(bundle.clone(), tool);
977 let v = render_at_boundary(
979 handler.compute(json!({ "inputs": { "filing_status": "alien" } })),
980 &ProvStamp::from_bundle(&bundle),
981 );
982 assert_eq!(
983 v["isError"],
984 json!(true),
985 "isError rides in structuredContent"
986 );
987 assert_eq!(v["code"], json!("invalid_input"));
988 assert!(v["provenance"]["combined_hash"].is_string());
989 }
990
991 #[test]
992 fn non_finite_output_surfaces_as_error_not_null() {
993 let err = finite_output_value(&CellValue::Number(f64::NAN), "3_Outputs!B3", "tax_owed")
995 .expect_err("NaN is rejected (WR-06)");
996 assert_eq!(err.code, "invalid_input");
997 let err = finite_output_value(&CellValue::Number(f64::INFINITY), "c", "k")
998 .expect_err("Infinity is rejected (WR-06)");
999 assert_eq!(err.code, "invalid_input");
1000 let ok = finite_output_value(&CellValue::Number(42.0), "c", "k").expect("finite ok");
1002 assert_eq!(ok, json!(42.0));
1003 }
1004
1005 #[test]
1006 fn project_tool_outputs_fails_closed_on_missing_declared_output() {
1007 let bundle = golden_bundle();
1012 let tool = &bundle.cell_map.tools[0];
1013 let run = RunResult::default();
1016 let err = project_tool_outputs(tool, &run)
1017 .expect_err("a missing declared output fails closed (WR-04)");
1018 assert_eq!(err.code, "invalid_input");
1019 assert!(
1020 err.reason.contains("was not computed by the bundle IR"),
1021 "the error names the cell_map/IR skew: {}",
1022 err.reason
1023 );
1024 assert!(
1026 tool.outputs
1027 .iter()
1028 .any(|e| err.reason.contains(&e.json_key) || err.reason.contains(&e.seed_coord)),
1029 "the error identifies the uncomputed output: {}",
1030 err.reason
1031 );
1032 }
1033
1034 #[test]
1035 fn project_tool_outputs_succeeds_when_all_declared_outputs_present() {
1036 let bundle = golden_bundle();
1039 let tool = &bundle.cell_map.tools[0];
1040 let mut run = RunResult::default();
1041 for entry in &tool.outputs {
1042 run.computed
1043 .insert(entry.seed_coord.clone(), CellValue::Number(1.0));
1044 }
1045 let projected = project_tool_outputs(tool, &run).expect("all-present projects");
1046 let obj = projected.as_object().expect("outputs is an object");
1047 assert_eq!(
1048 obj.len(),
1049 tool.outputs.len(),
1050 "every declared output is projected"
1051 );
1052 }
1053
1054 #[test]
1055 fn tool_advertises_non_empty_output_schema() {
1056 let handler = calc_handler();
1057 let meta = handler.metadata().expect("metadata present");
1058 let schema = meta
1059 .output_schema
1060 .expect("outputSchema advertised (WBSV-07)");
1061 let outputs = &schema["properties"]["outputs"]["properties"];
1062 assert!(
1063 outputs.as_object().is_some_and(|o| !o.is_empty()),
1064 "outputSchema enumerates the named outputs"
1065 );
1066 }
1067
1068 #[test]
1071 fn sanitize_lowercases_and_maps_space_to_underscore() {
1072 assert_eq!(
1073 sanitize_tool_name("Calculate Tax").unwrap(),
1074 "calculate_tax"
1075 );
1076 }
1077
1078 #[test]
1079 fn sanitize_lowercases_existing_underscore_name() {
1080 assert_eq!(
1081 sanitize_tool_name("Calculate_Tax").unwrap(),
1082 "calculate_tax"
1083 );
1084 }
1085
1086 #[test]
1087 fn sanitize_collapses_illegal_runs_to_single_underscore() {
1088 assert_eq!(sanitize_tool_name("a b").unwrap(), "a_b");
1089 assert_eq!(sanitize_tool_name("a@@b").unwrap(), "a_b");
1090 assert_eq!(sanitize_tool_name("a@ @b").unwrap(), "a_b");
1091 }
1092
1093 #[test]
1094 fn sanitize_trims_leading_and_trailing_edges() {
1095 assert_eq!(sanitize_tool_name(" hello ").unwrap(), "hello");
1096 assert_eq!(sanitize_tool_name("__hi__").unwrap(), "hi");
1097 assert_eq!(sanitize_tool_name("-hi-").unwrap(), "hi");
1098 }
1099
1100 #[test]
1101 fn sanitize_truncates_to_64() {
1102 let long = "a".repeat(200);
1103 let out = sanitize_tool_name(&long).unwrap();
1104 assert_eq!(out.len(), 64);
1105 assert!(out.chars().all(|c| c == 'a'));
1106 }
1107
1108 #[test]
1109 fn sanitize_rejects_empty_and_all_illegal() {
1110 assert!(sanitize_tool_name("").is_err());
1111 assert!(sanitize_tool_name(" ").is_err());
1112 assert!(sanitize_tool_name("@@@").is_err());
1113 assert!(sanitize_tool_name("日本語").is_err());
1114 }
1115
1116 #[test]
1117 fn workbook_tool_handler_metadata_carries_both_schemas() {
1118 let handler = calc_handler();
1119 let meta = handler.metadata().expect("metadata present");
1120 assert_eq!(
1122 meta.name,
1123 sanitize_tool_name(&handler.tool.name).unwrap(),
1124 "metadata name is the sanitized tool name"
1125 );
1126 assert!(meta.input_schema.is_object(), "carries an input schema");
1127 assert!(meta.output_schema.is_some(), "carries an output schema");
1128 }
1129
1130 #[test]
1133 fn explain_emits_ordered_trace_and_generic_manifest_annotations() {
1134 let handler = ExplainHandler::new(golden_bundle());
1135 let v = handler
1136 .compute(json!({ "inputs": { "gross_income": 60000.0, "filing_status": "single" } }))
1137 .expect("explain succeeds");
1138
1139 let steps = v["steps"].as_array().expect("steps is an array");
1141 assert!(!steps.is_empty(), "explain emits derivation steps");
1142 for step in steps {
1143 assert_eq!(step["step"], json!("derivation"));
1144 assert!(step["cell"].is_string());
1145 }
1146
1147 let annotations = v["annotations"].as_object().expect("annotations object");
1151 assert!(annotations.contains_key("bracket_boundary_1"));
1152 assert!(annotations.contains_key("bracket_boundary_2"));
1153 assert_eq!(
1154 annotations["bracket_boundary_1"]["target"],
1155 json!("2_Brackets!A2")
1156 );
1157 assert!(annotations["bracket_boundary_1"]["meaning"].is_string());
1158
1159 assert!(v["provenance"]["combined_hash"].is_string());
1160 }
1161
1162 #[test]
1163 fn explain_invalid_input_returns_iserror() {
1164 let bundle = golden_bundle();
1165 let handler = ExplainHandler::new(bundle.clone());
1166 let v = render_at_boundary(
1167 handler.compute(json!({ "inputs": { "filing_status": "alien" } })),
1168 &ProvStamp::from_bundle(&bundle),
1169 );
1170 assert_eq!(v["isError"], json!(true));
1171 assert_eq!(v["code"], json!("invalid_input"));
1172 }
1173
1174 #[test]
1177 fn get_manifest_returns_curated_projection_with_no_input() {
1178 let bundle = golden_bundle();
1179 let v = curated_manifest(&bundle, &ProvStamp::from_bundle(&bundle));
1180 assert_eq!(v["bundle_id"], json!("tax-calc"));
1181 assert_eq!(v["version"], json!("1.1.0"));
1182 assert!(v["combined_hash"].is_string());
1183 let inputs = v["inputs"].as_array().expect("inputs array");
1185 assert_eq!(
1186 inputs.len(),
1187 4,
1188 "four inputs projected (income, filing, deductions, withheld)"
1189 );
1190 assert!(inputs.iter().all(|i| i["tier"].is_string()));
1191 let outputs = v["outputs"].as_array().expect("outputs array");
1192 assert_eq!(
1193 outputs.len(),
1194 7,
1195 "seven outputs projected (4 numeric tax + the WBVER-01/D-07 text+bool \
1196 formula outputs + 1 refund) across the two tools"
1197 );
1198 assert!(v["governed_data"].is_array());
1199 assert!(v["changelog"].is_array());
1200 assert!(v["provenance"]["combined_hash"].is_string());
1201 }
1202
1203 #[test]
1208 fn get_manifest_advertises_the_stripped_served_keys() {
1209 use super::super::schema::output_schema_for_manifest;
1210 use std::collections::BTreeSet;
1211 let bundle = golden_bundle();
1212 let v = curated_manifest(&bundle, &ProvStamp::from_bundle(&bundle));
1213
1214 let manifest_inputs: BTreeSet<String> = v["inputs"]
1216 .as_array()
1217 .expect("inputs array")
1218 .iter()
1219 .map(|i| i["name"].as_str().expect("input name string").to_string())
1220 .collect();
1221 let manifest_outputs: BTreeSet<String> = v["outputs"]
1222 .as_array()
1223 .expect("outputs array")
1224 .iter()
1225 .map(|o| o["name"].as_str().expect("output name string").to_string())
1226 .collect();
1227
1228 for name in manifest_inputs.iter().chain(manifest_outputs.iter()) {
1230 assert!(
1231 !name.starts_with("in_") && !name.starts_with("out_"),
1232 "advertised get_manifest name `{name}` is stripped (no governance prefix)"
1233 );
1234 }
1235
1236 let wide_in = input_schema_for_manifest(&bundle.manifest, &bundle.cell_map);
1240 let served_inputs: BTreeSet<String> = wide_in["properties"]["inputs"]["properties"]
1241 .as_object()
1242 .map(|m| m.keys().cloned().collect())
1243 .unwrap_or_default();
1244 let wide_out = output_schema_for_manifest(&bundle.manifest, &bundle.cell_map);
1245 let served_outputs: BTreeSet<String> = wide_out["properties"]["outputs"]["properties"]
1246 .as_object()
1247 .map(|m| m.keys().cloned().collect())
1248 .unwrap_or_default();
1249
1250 assert_eq!(
1251 manifest_inputs, served_inputs,
1252 "get_manifest input names == the workbook-wide served input keys (stripped)"
1253 );
1254 assert_eq!(
1255 manifest_outputs, served_outputs,
1256 "get_manifest output names == the workbook-wide served output keys (stripped)"
1257 );
1258
1259 for tool in &bundle.cell_map.tools {
1263 let in_schema = input_schema_for_tool(&bundle.manifest, &bundle.cell_map, tool);
1264 if let Some(props) = in_schema["properties"]["inputs"]["properties"].as_object() {
1265 for key in props.keys() {
1266 assert!(
1267 manifest_inputs.contains(key),
1268 "served per-tool input key `{key}` is discoverable in get_manifest"
1269 );
1270 }
1271 }
1272 }
1273 }
1274
1275 #[test]
1278 fn diff_version_serves_recorded_changelog() {
1279 let bundle = golden_bundle();
1280 let v = serve_changelog(&bundle, &ProvStamp::from_bundle(&bundle));
1281
1282 assert_eq!(v["from_version"], json!(bundle.changelog.from_version));
1284 assert_eq!(v["to_version"], json!(bundle.changelog.to_version));
1285 assert_eq!(v["summary"], json!(bundle.changelog.summary));
1286 let deltas = v["deltas"].as_array().expect("deltas array");
1287 assert_eq!(deltas.len(), bundle.changelog.deltas.len());
1288 if let Some(first) = deltas.first() {
1289 assert!(first["region"].is_string());
1290 assert!(first["change_class"].is_string());
1291 assert!(first["severity"].is_string());
1292 }
1293 assert!(v["provenance"]["combined_hash"].is_string());
1294 assert!(
1295 v.get("isError").is_none(),
1296 "a served changelog is not an error"
1297 );
1298 }
1299
1300 #[test]
1301 fn diff_version_advertises_output_schema() {
1302 let handler = DiffVersionHandler::new(golden_bundle());
1303 let meta = handler.metadata().expect("metadata present");
1304 let schema = meta.output_schema.expect("output schema advertised");
1305 assert_eq!(
1306 schema["properties"]["from_version"]["type"],
1307 json!("string")
1308 );
1309 assert_eq!(schema["properties"]["deltas"]["type"], json!("array"));
1310 }
1311
1312 #[test]
1315 fn render_workbook_returns_uri_pointer_not_bytes() {
1316 let bundle = golden_bundle();
1317 let handler = RenderWorkbookHandler::new(bundle.clone());
1318 let v = handler
1319 .compute(json!({ "inputs": { "gross_income": 60000.0, "filing_status": "single" } }))
1320 .expect("render_workbook succeeds");
1321
1322 let uri = v["resource_uri"]
1324 .as_str()
1325 .expect("resource_uri is a string");
1326 assert!(
1327 uri.starts_with(render_uri::RENDER_URI_PREFIX),
1328 "returns a workbook:// pointer"
1329 );
1330 assert!(
1331 v.get("bytes").is_none() && v.get("data").is_none(),
1332 "the bytes are NOT in the tool response"
1333 );
1334 let decoded = render_uri::decode(uri).expect("pointer decodes");
1336 assert_eq!(decoded.provenance, ProvStamp::from_bundle(&bundle));
1337 assert_eq!(decoded.provenance.combined_hash, bundle.stamp.combined);
1338 assert!(v["provenance"]["combined_hash"].is_string());
1340 assert!(v.get("isError").is_none(), "a success is not an error");
1341 }
1342
1343 #[test]
1344 fn render_workbook_invalid_input_returns_iserror() {
1345 let bundle = golden_bundle();
1346 let handler = RenderWorkbookHandler::new(bundle.clone());
1347 let v = render_at_boundary(
1348 handler.compute(json!({ "inputs": { "filing_status": "alien" } })),
1349 &ProvStamp::from_bundle(&bundle),
1350 );
1351 assert_eq!(v["isError"], json!(true), "isError rides in the payload");
1352 assert_eq!(v["code"], json!("invalid_input"));
1353 assert!(v["provenance"]["combined_hash"].is_string());
1354 }
1355
1356 #[test]
1357 fn render_workbook_advertises_non_empty_output_schema() {
1358 let handler = RenderWorkbookHandler::new(golden_bundle());
1359 let meta = handler.metadata().expect("metadata present");
1360 let schema = meta
1361 .output_schema
1362 .expect("outputSchema advertised (WBSV-07)");
1363 assert_eq!(
1364 schema["properties"]["resource_uri"]["type"],
1365 json!("string")
1366 );
1367 }
1368
1369 #[test]
1370 fn render_workbook_inputs_only_mode_encodes_into_uri() {
1371 let bundle = golden_bundle();
1375 let handler = RenderWorkbookHandler::new(bundle.clone());
1376
1377 let io = handler
1378 .compute(json!({
1379 "inputs": { "gross_income": 60000.0, "filing_status": "single" },
1380 "mode": "inputs_only",
1381 }))
1382 .expect("inputs_only render_workbook succeeds");
1383 let io_uri = io["resource_uri"].as_str().expect("resource_uri string");
1384 let io_decoded = render_uri::decode(io_uri).expect("pointer decodes");
1385 assert_eq!(
1386 io_decoded.mode,
1387 RenderMode::InputsOnly,
1388 "mode:inputs_only rides into the URI payload"
1389 );
1390
1391 let default = handler
1392 .compute(json!({
1393 "inputs": { "gross_income": 60000.0, "filing_status": "single" }
1394 }))
1395 .expect("no-mode render_workbook succeeds");
1396 let d_uri = default["resource_uri"]
1397 .as_str()
1398 .expect("resource_uri string");
1399 let d_decoded = render_uri::decode(d_uri).expect("pointer decodes");
1400 assert_eq!(
1401 d_decoded.mode,
1402 RenderMode::Filled,
1403 "no mode arg defaults to Filled (no regression)"
1404 );
1405 }
1406
1407 #[test]
1408 fn render_workbook_unknown_mode_is_iserror_not_panic() {
1409 let bundle = golden_bundle();
1413 let handler = RenderWorkbookHandler::new(bundle.clone());
1414 let v = render_at_boundary(
1415 handler.compute(json!({
1416 "inputs": { "gross_income": 60000.0, "filing_status": "single" },
1417 "mode": "bogus",
1418 })),
1419 &ProvStamp::from_bundle(&bundle),
1420 );
1421 assert_eq!(
1422 v["isError"],
1423 json!(true),
1424 "unknown mode is an isError envelope"
1425 );
1426 assert_eq!(v["code"], json!("invalid_input"));
1427 assert!(
1429 handler
1430 .compute(json!({
1431 "inputs": { "gross_income": 60000.0, "filing_status": "single" },
1432 "mode": "bogus",
1433 }))
1434 .is_err(),
1435 "unknown mode → Err, never a silent Filled"
1436 );
1437 }
1438
1439 #[test]
1440 fn mode_is_render_only_and_never_leaks_into_calculate_or_explain() {
1441 let bundle = golden_bundle();
1446
1447 let render_schema = render_input_schema_for_manifest(&bundle.manifest, &bundle.cell_map);
1449 assert!(
1450 render_schema["properties"]["mode"].is_object(),
1451 "render schema advertises mode"
1452 );
1453 assert_eq!(
1454 render_schema["properties"]["mode"]["enum"],
1455 json!(["filled", "inputs_only"]),
1456 "advertise == accept: the render mode enum matches the handler's accepted values"
1457 );
1458
1459 let calc = calc_handler();
1460 let calc_schema = calc.metadata().expect("calc meta").input_schema;
1461 assert!(
1462 calc_schema["properties"].get("mode").is_none(),
1463 "calculate schema does NOT advertise mode"
1464 );
1465 let explain = ExplainHandler::new(bundle.clone());
1466 let explain_schema = explain.metadata().expect("explain meta").input_schema;
1467 assert!(
1468 explain_schema["properties"].get("mode").is_none(),
1469 "explain schema does NOT advertise mode"
1470 );
1471
1472 let with_mode = json!({ "inputs": { "gross_income": 60000.0 }, "mode": "inputs_only" });
1474 assert!(
1475 validate_input(with_mode.clone(), &bundle.manifest, &bundle.cell_map).is_err(),
1476 "a mode key is rejected by validate_input (deny_unknown_fields)"
1477 );
1478 }
1479
1480 #[test]
1485 fn verify_accuracy_no_filter_reconciles_golden_green() {
1486 let handler = VerifyAccuracyHandler::new(golden_bundle());
1487 let v = handler
1488 .compute(json!({}))
1489 .expect("verify_accuracy succeeds");
1490
1491 assert_eq!(
1492 v["all_within_tol"],
1493 json!(true),
1494 "the golden reconciles green"
1495 );
1496 assert_eq!(
1499 v["cells_checked"],
1500 json!(7),
1501 "all authored oracle rows are checked"
1502 );
1503
1504 let tools = v["tools"].as_array().expect("tools array");
1505 let calc = tools
1506 .iter()
1507 .find(|t| t["tool"] == json!("Calculate_Tax"))
1508 .expect("Calculate_Tax present");
1509 let keys: Vec<&str> = calc["outputs"]
1510 .as_array()
1511 .unwrap()
1512 .iter()
1513 .map(|r| r["key"].as_str().unwrap())
1514 .collect();
1515 assert!(
1516 keys.contains(&"bracket_label"),
1517 "the text output is reconciled"
1518 );
1519 assert!(
1520 keys.contains(&"is_taxable"),
1521 "the bool output is reconciled"
1522 );
1523 for row in calc["outputs"].as_array().unwrap() {
1525 assert!(
1526 row["cell"].as_str().is_some_and(|c| c.contains('!')),
1527 "each output row carries its A1 cell address"
1528 );
1529 assert_eq!(row["within_tol"], json!(true));
1530 }
1531 }
1532
1533 #[test]
1537 fn verify_accuracy_filter_scopes_and_recomputes_aggregates() {
1538 let handler = VerifyAccuracyHandler::new(golden_bundle());
1539 let v = handler
1540 .compute(json!({ "tool": "Estimate_Refund" }))
1541 .expect("filtered verify_accuracy succeeds");
1542
1543 let tools = v["tools"].as_array().expect("tools array");
1544 assert_eq!(tools.len(), 1, "only the filtered tool is reported");
1545 assert_eq!(tools[0]["tool"], json!("Estimate_Refund"));
1546 assert_eq!(
1549 v["cells_checked"],
1550 json!(1),
1551 "cells_checked is the filtered tool's compared-row count, not the full rollup"
1552 );
1553 assert_eq!(v["all_within_tol"], json!(true));
1554 }
1555
1556 #[test]
1559 fn verify_accuracy_unknown_filter_errors_listing_tools() {
1560 let handler = VerifyAccuracyHandler::new(golden_bundle());
1561 let err = handler
1562 .compute(json!({ "tool": "nonexistent" }))
1563 .expect_err("an unknown tool filter is an Err (D-03)");
1564 assert_eq!(err.code, "invalid_input");
1565 let names = err.allowed.clone().unwrap_or_default();
1567 assert!(
1568 names.contains(&"Calculate_Tax".to_string())
1569 && names.contains(&"Estimate_Refund".to_string()),
1570 "the D-03 error lists the available tool names, got {names:?}"
1571 );
1572 }
1573
1574 #[tokio::test]
1577 async fn verify_accuracy_unknown_filter_renders_iserror_envelope() {
1578 let handler = VerifyAccuracyHandler::new(golden_bundle());
1579 let rendered = handler
1580 .handle(json!({ "tool": "nope" }), RequestHandlerExtra::default())
1581 .await
1582 .expect("handle never returns a protocol error");
1583 assert_eq!(
1584 rendered["isError"],
1585 json!(true),
1586 "renders as an isError envelope"
1587 );
1588 }
1589
1590 #[test]
1592 fn verify_accuracy_non_string_filter_errors() {
1593 let handler = VerifyAccuracyHandler::new(golden_bundle());
1594 assert!(
1595 handler.compute(json!({ "tool": 42 })).is_err(),
1596 "a non-string tool filter is an Err, not a panic"
1597 );
1598 }
1599
1600 #[test]
1603 fn verify_accuracy_schemas_advertise_report_and_filter() {
1604 let handler = VerifyAccuracyHandler::new(golden_bundle());
1605 let meta = handler.metadata().expect("verify_accuracy metadata");
1606 assert_eq!(meta.name, "verify_accuracy");
1607 assert!(
1608 meta.input_schema["properties"].get("tool").is_some(),
1609 "the input schema advertises the optional tool filter (advertise == accept)"
1610 );
1611 let out = meta.output_schema.expect("output schema present");
1612 for field in ["tolerance", "all_within_tol", "cells_checked", "tools"] {
1613 assert!(
1614 out["properties"].get(field).is_some(),
1615 "output schema advertises {field}"
1616 );
1617 }
1618 }
1619}