1#![cfg_attr(
14 not(test),
15 deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)
16)]
17
18use std::collections::HashSet;
19
20use serde_json::{json, Map, Value};
21
22use pmcp_workbook_runtime::{CellEntry, CellMap, CellRole, Dtype, Manifest, Tool};
23
24pub(crate) fn dtype_json_type(dtype: Dtype) -> &'static str {
27 match dtype {
28 Dtype::Number => "number",
29 Dtype::Text => "string",
30 Dtype::Bool => "boolean",
31 }
32}
33
34fn role_for_seed<'a>(manifest: &'a Manifest, seed_coord: &str) -> Option<&'a CellRole> {
37 pmcp_workbook_runtime::role_for_cell(manifest, seed_coord)
38}
39
40fn output_column_schema(unit: Option<&str>, role: Option<&CellRole>) -> Value {
46 let dtype = role.map_or(Dtype::Number, |r| r.dtype);
47 let mut value_prop = Map::new();
48 value_prop.insert("type".to_string(), json!(dtype_json_type(dtype)));
49 if let Some(u) = unit {
50 value_prop.insert("unit".to_string(), json!(u));
51 }
52
53 let mut props = Map::new();
54 props.insert("value".to_string(), Value::Object(value_prop));
55 props.insert("unit".to_string(), json!({ "type": ["string", "null"] }));
56
57 let mut col = Map::new();
58 col.insert("type".to_string(), json!("object"));
59 col.insert("additionalProperties".to_string(), json!(false));
60 col.insert("properties".to_string(), Value::Object(props));
61 col.insert("required".to_string(), json!(["value"]));
62 if let Some(meaning) = role.and_then(|r| r.meaning.as_deref()) {
63 col.insert("description".to_string(), json!(meaning));
64 }
65 Value::Object(col)
66}
67
68#[must_use]
78pub fn output_schema_for_manifest(manifest: &Manifest, cell_map: &CellMap) -> Value {
79 let all_outputs: Vec<CellEntry> = cell_map
83 .tools
84 .iter()
85 .flat_map(|t| t.outputs.iter().cloned())
86 .collect();
87 let output_props = output_props_for_entries(manifest, &all_outputs);
88
89 let mut success = Map::new();
90 success.insert(
91 "outputs".to_string(),
92 json!({
93 "type": "object",
94 "additionalProperties": false,
95 "properties": Value::Object(output_props),
96 }),
97 );
98 success.insert(
99 "accepted_overrides".to_string(),
100 json!({ "type": "array", "items": { "type": "string" } }),
101 );
102 result_envelope_schema(success)
103}
104
105fn output_props_for_entries(manifest: &Manifest, outputs: &[CellEntry]) -> Map<String, Value> {
109 let mut output_props = Map::new();
110 for entry in outputs {
111 let role = role_for_seed(manifest, &entry.seed_coord);
112 output_props.insert(
113 entry.json_key.clone(),
114 output_column_schema(entry.unit.as_deref(), role),
115 );
116 }
117 output_props
118}
119
120#[must_use]
126pub fn output_schema_for_tool(manifest: &Manifest, tool: &Tool) -> Value {
127 let output_props = output_props_for_entries(manifest, &tool.outputs);
128
129 let mut success = Map::new();
130 success.insert(
131 "outputs".to_string(),
132 json!({
133 "type": "object",
134 "additionalProperties": false,
135 "properties": Value::Object(output_props),
136 }),
137 );
138 success.insert(
139 "accepted_overrides".to_string(),
140 json!({ "type": "array", "items": { "type": "string" } }),
141 );
142 result_envelope_schema(success)
143}
144
145#[must_use]
158pub fn result_envelope_schema(success_props: Map<String, Value>) -> Value {
159 let mut props = success_props;
160 props.insert("isError".to_string(), json!({ "type": "boolean" }));
162 props.insert("code".to_string(), json!({ "type": "string" }));
163 props.insert("reason".to_string(), json!({ "type": "string" }));
164 props.insert("field".to_string(), json!({ "type": "string" }));
165 props.insert(
166 "allowed".to_string(),
167 json!({ "type": "array", "items": {} }),
168 );
169 props.insert("range".to_string(), json!({ "type": "array" }));
170 props.insert(
171 "required".to_string(),
172 json!({ "type": "array", "items": { "type": "string" } }),
173 );
174 props.insert("provenance".to_string(), provenance_schema());
176
177 json!({
178 "type": "object",
179 "additionalProperties": true,
180 "properties": Value::Object(props),
181 "required": ["provenance"],
182 })
183}
184
185#[must_use]
190pub fn explain_output_schema() -> Value {
191 let mut success = Map::new();
192 success.insert(
193 "steps".to_string(),
194 json!({
195 "type": "array",
196 "description": "Ordered business-language derivation steps.",
197 "items": {
198 "type": "object",
199 "additionalProperties": true,
200 "properties": {
201 "step": { "type": "string" },
202 "cell": { "type": "string" },
203 },
204 },
205 }),
206 );
207 success.insert(
208 "annotations".to_string(),
209 json!({
210 "type": "object",
211 "description": "Manifest-declared annotations (keyed by AnnotationDecl name).",
212 "additionalProperties": { "type": "object" },
213 }),
214 );
215 result_envelope_schema(success)
216}
217
218#[must_use]
222pub fn get_manifest_output_schema() -> Value {
223 let mut success = Map::new();
224 success.insert("bundle_id".to_string(), json!({ "type": "string" }));
225 success.insert("version".to_string(), json!({ "type": "string" }));
226 success.insert("combined_hash".to_string(), json!({ "type": "string" }));
227 for field in ["inputs", "outputs", "governed_data", "changelog"] {
228 success.insert(
229 field.to_string(),
230 json!({ "type": "array", "items": { "type": "object" } }),
231 );
232 }
233 result_envelope_schema(success)
234}
235
236#[must_use]
241pub fn diff_version_output_schema() -> Value {
242 let mut success = Map::new();
243 success.insert("from_version".to_string(), json!({ "type": "string" }));
244 success.insert("to_version".to_string(), json!({ "type": "string" }));
245 success.insert(
246 "deltas".to_string(),
247 json!({
248 "type": "array",
249 "description": "Per-output change records (region, change class, old/new \
250 meaning+unit+provenance, drift/redefinition severity).",
251 "items": {
252 "type": "object",
253 "additionalProperties": true,
254 "properties": {
255 "region": { "type": "string" },
256 "change_class": { "type": "string" },
257 "severity": { "type": "string" },
258 },
259 },
260 }),
261 );
262 success.insert(
263 "summary".to_string(),
264 json!({ "type": "string", "description": "Human-readable transition summary." }),
265 );
266 result_envelope_schema(success)
267}
268
269#[must_use]
273pub fn render_workbook_output_schema() -> Value {
274 let mut success = Map::new();
275 success.insert(
276 "resource_uri".to_string(),
277 json!({
278 "type": "string",
279 "description": "A provenance-bound workbook:// resource URI. Read it via \
280 resources/read to obtain the base64-encoded .xlsx, which is \
281 regenerated statelessly from the URI on each read. The URI \
282 encodes the inputs — treat it as sensitive.",
283 }),
284 );
285 success.insert(
286 "mime_type".to_string(),
287 json!({
288 "type": "string",
289 "description": "The MIME type of the resource the URI resolves to (the OOXML \
290 spreadsheet type).",
291 }),
292 );
293 result_envelope_schema(success)
294}
295
296#[must_use]
301pub fn verify_accuracy_input_schema() -> Value {
302 json!({
303 "type": "object",
304 "additionalProperties": false,
305 "properties": {
306 "tool": {
307 "type": "string",
308 "description": "Optional. Scope the report to one registered tool by name. \
309 Omit to reconcile every tool. An unknown name returns an \
310 error listing the available tools.",
311 },
312 },
313 })
314}
315
316#[must_use]
322pub fn verify_accuracy_output_schema() -> Value {
323 let mut success = Map::new();
324 success.insert(
325 "tolerance".to_string(),
326 json!({ "type": "number", "description": "The tolerance the report was graded at (±)." }),
327 );
328 success.insert(
329 "all_within_tol".to_string(),
330 json!({
331 "type": "boolean",
332 "description": "True iff every compared output across the reported tools is \
333 within tolerance.",
334 }),
335 );
336 success.insert(
337 "cells_checked".to_string(),
338 json!({
339 "type": "integer",
340 "description": "The number of output rows actually compared (an empty-oracle \
341 tool contributes 0).",
342 }),
343 );
344 success.insert(
345 "tools".to_string(),
346 json!({
347 "type": "array",
348 "description": "One report per reconciled tool.",
349 "items": {
350 "type": "object",
351 "additionalProperties": true,
352 "properties": {
353 "tool": { "type": "string" },
354 "all_within_tol": { "type": "boolean" },
355 "outputs": {
356 "type": "array",
357 "items": {
358 "type": "object",
359 "additionalProperties": true,
360 "properties": {
361 "key": { "type": "string" },
362 "cell": { "type": ["string", "null"] },
363 "abs_delta": { "type": "number" },
364 "within_tol": { "type": "boolean" },
365 },
366 },
367 },
368 },
369 },
370 }),
371 );
372 result_envelope_schema(success)
373}
374
375#[must_use]
378pub fn provenance_schema() -> Value {
379 json!({
380 "type": "object",
381 "additionalProperties": false,
382 "properties": {
383 "bundle_id": { "type": "string" },
384 "version": { "type": "string" },
385 "combined_hash": { "type": "string" },
386 },
387 "required": ["bundle_id", "version", "combined_hash"],
388 })
389}
390
391#[must_use]
397pub fn input_schema_for_manifest(manifest: &Manifest, cell_map: &CellMap) -> Value {
398 let mut input_props = Map::new();
399 for entry in &cell_map.inputs {
400 input_props.insert(
401 entry.json_key.clone(),
402 input_prop_for_entry(manifest, entry),
403 );
404 }
405 assemble_input_schema(manifest, input_props)
406}
407
408#[must_use]
417pub fn input_schema_for_tool(manifest: &Manifest, cell_map: &CellMap, tool: &Tool) -> Value {
418 let reached: HashSet<&str> = tool.input_keys.iter().map(String::as_str).collect();
421 let project_all = tool.input_keys.is_empty();
430 let mut input_props = Map::new();
431 for entry in &cell_map.inputs {
432 if project_all || reached.contains(entry.json_key.as_str()) {
434 input_props.insert(
435 entry.json_key.clone(),
436 input_prop_for_entry(manifest, entry),
437 );
438 }
439 }
440 assemble_input_schema(manifest, input_props)
441}
442
443fn input_prop_for_entry(manifest: &Manifest, entry: &CellEntry) -> Value {
448 let role = role_for_seed(manifest, &entry.seed_coord);
449 let dtype = role.map_or(Dtype::Number, |r| r.dtype);
450 let mut prop = Map::new();
451 prop.insert("type".to_string(), json!(dtype_json_type(dtype)));
452 if let Some(unit) = entry.unit.as_deref() {
453 prop.insert("unit".to_string(), json!(unit));
454 }
455 if let Some(meaning) = role.and_then(|r| r.meaning.as_deref()) {
456 prop.insert("description".to_string(), json!(meaning));
457 }
458 if let Some(allowed) = role.and_then(|r| r.allowed_values.as_ref()) {
462 prop.insert("enum".to_string(), json!(allowed));
463 }
464 Value::Object(prop)
465}
466
467fn assemble_input_schema(manifest: &Manifest, input_props: Map<String, Value>) -> Value {
472 let mut override_props = Map::new();
480 for key in crate::workbook::input::variable_tier_keys(manifest) {
481 override_props.insert(
482 key,
483 json!({ "type": ["number", "string", "boolean", "null"] }),
484 );
485 }
486
487 json!({
488 "type": "object",
489 "additionalProperties": false,
490 "properties": {
491 "inputs": {
492 "type": "object",
493 "additionalProperties": false,
494 "properties": Value::Object(input_props),
495 },
496 "overrides": {
497 "type": "object",
498 "additionalProperties": { "type": ["number", "string", "boolean", "null"] },
499 "properties": Value::Object(override_props),
500 "description": "Variable-tier parameter overrides, keyed by parameter \
501 name or cell key. Strict (BA-governed) constants are rejected.",
502 },
503 },
504 })
505}
506
507#[must_use]
519pub fn render_input_schema_for_manifest(manifest: &Manifest, cell_map: &CellMap) -> Value {
520 let mut schema = input_schema_for_manifest(manifest, cell_map);
521 if let Some(props) = schema.get_mut("properties").and_then(Value::as_object_mut) {
522 props.insert(
523 "mode".to_string(),
524 json!({
525 "type": "string",
526 "enum": ["filled", "inputs_only"],
527 "description": "Render mode (WBVER-02). 'filled' (default) writes \
528 formula cells with their cached result; 'inputs_only' \
529 writes bare formulas so Excel recomputes every output \
530 (double-entry verification copy).",
531 }),
532 );
533 }
534 schema
535}
536
537#[must_use]
539pub fn empty_input_schema() -> Value {
540 json!({ "type": "object", "additionalProperties": false })
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546 use pmcp_workbook_runtime::CellValue;
547 use pmcp_workbook_runtime::{CellEntry, CellMap, InputTier, Role, Tool};
548
549 fn input_role(
550 cell: &str,
551 dtype: Dtype,
552 meaning: &str,
553 allowed: Option<Vec<String>>,
554 ) -> CellRole {
555 CellRole {
556 cell: cell.to_string(),
557 role: Role::Input,
558 name: None,
559 unit: Some("USD".to_string()),
560 meaning: Some(meaning.to_string()),
561 dtype,
562 colour_evidence: None,
563 source: "test".to_string(),
564 notes: None,
565 tier: Some(InputTier::Variable {
566 default: CellValue::Number(0.0),
567 }),
568 allowed_values: allowed,
569 }
570 }
571
572 fn output_role(cell: &str, meaning: &str) -> CellRole {
573 CellRole {
574 cell: cell.to_string(),
575 role: Role::Output,
576 name: None,
577 unit: Some("USD".to_string()),
578 meaning: Some(meaning.to_string()),
579 dtype: Dtype::Number,
580 colour_evidence: None,
581 source: "test".to_string(),
582 notes: None,
583 tier: None,
584 allowed_values: None,
585 }
586 }
587
588 fn manifest_with(cells: Vec<CellRole>) -> Manifest {
589 Manifest {
590 schema_version: 1,
591 workflow: "tax-calc".to_string(),
592 workbook_hash: None,
593 ratified: true,
594 ratified_by: None,
595 ratified_at: None,
596 cells,
597 loop_block: None,
598 governed_data: vec![],
599 changelog: vec![],
600 capability_calls: vec![],
601 annotations: vec![],
602 }
603 }
604
605 fn three_input_manifest_and_map() -> (Manifest, CellMap) {
606 let manifest = manifest_with(vec![
607 input_role("1_Inputs!B2", Dtype::Number, "Gross income", None),
608 input_role(
609 "1_Inputs!B3",
610 Dtype::Text,
611 "Filing status",
612 Some(vec!["single".to_string(), "married_joint".to_string()]),
613 ),
614 input_role("1_Inputs!B4", Dtype::Number, "Deductions", None),
615 output_role("3_Outputs!B2", "Taxable income"),
616 output_role("3_Outputs!B3", "Tax owed"),
617 ]);
618 let cell_map = CellMap {
619 inputs: vec![
620 CellEntry {
621 json_key: "gross_income".to_string(),
622 seed_coord: "1_Inputs!B2".to_string(),
623 unit: Some("USD".to_string()),
624 },
625 CellEntry {
626 json_key: "filing_status".to_string(),
627 seed_coord: "1_Inputs!B3".to_string(),
628 unit: None,
629 },
630 CellEntry {
631 json_key: "deductions".to_string(),
632 seed_coord: "1_Inputs!B4".to_string(),
633 unit: Some("USD".to_string()),
634 },
635 ],
636 tools: vec![Tool {
637 name: "calculate".to_string(),
638 description: None,
639 input_keys: Vec::new(),
640 outputs: vec![
641 CellEntry {
642 json_key: "taxable_income".to_string(),
643 seed_coord: "3_Outputs!B2".to_string(),
644 unit: Some("USD".to_string()),
645 },
646 CellEntry {
647 json_key: "tax_owed".to_string(),
648 seed_coord: "3_Outputs!B3".to_string(),
649 unit: Some("USD".to_string()),
650 },
651 ],
652 oracle: std::collections::BTreeMap::new(),
653 }],
654 };
655 (manifest, cell_map)
656 }
657
658 #[test]
659 fn input_schema_is_strict_and_projects_all_inputs() {
660 let (m, cm) = three_input_manifest_and_map();
661 let schema = input_schema_for_manifest(&m, &cm);
662 assert_eq!(schema["additionalProperties"], false);
664 let props = &schema["properties"]["inputs"]["properties"];
665 assert_eq!(props["gross_income"]["type"], json!("number"));
667 assert_eq!(props["gross_income"]["unit"], json!("USD"));
668 assert_eq!(props["gross_income"]["description"], json!("Gross income"));
669 assert_eq!(props["filing_status"]["type"], json!("string"));
670 assert_eq!(props["deductions"]["type"], json!("number"));
671 assert_eq!(
673 schema["properties"]["inputs"]["additionalProperties"],
674 false
675 );
676 }
677
678 #[test]
679 fn input_schema_emits_enum_for_allowed_values_and_keeps_it_optional() {
680 let (m, cm) = three_input_manifest_and_map();
681 let schema = input_schema_for_manifest(&m, &cm);
682 let props = &schema["properties"]["inputs"]["properties"];
683 assert_eq!(
684 props["filing_status"]["enum"],
685 json!(["single", "married_joint"]),
686 "allowed_values surfaces as a JSON-Schema enum (verbatim order)"
687 );
688 assert!(props["gross_income"].get("enum").is_none());
690 assert!(schema["properties"]["inputs"].get("required").is_none());
692 }
693
694 #[test]
695 fn output_schema_is_non_empty_and_carries_every_named_output() {
696 let (m, cm) = three_input_manifest_and_map();
697 let schema = output_schema_for_manifest(&m, &cm);
698 let outputs = &schema["properties"]["outputs"]["properties"];
699 assert!(outputs["taxable_income"].is_object());
701 assert!(outputs["tax_owed"].is_object());
702 assert_eq!(
703 outputs["taxable_income"]["properties"]["value"]["unit"],
704 "USD"
705 );
706 assert_eq!(
707 outputs["taxable_income"]["properties"]["value"]["type"],
708 "number"
709 );
710 let headline_key = ["supply", "_", "total"].concat();
715 assert!(
716 schema["properties"].get(&headline_key).is_none(),
717 "no privileged headline field at the root (S-1)"
718 );
719 assert!(schema["properties"]["provenance"].is_object());
721 assert!(
722 !outputs
723 .as_object()
724 .expect("outputs is an object")
725 .is_empty(),
726 "outputSchema must enumerate at least one output"
727 );
728 }
729
730 #[test]
731 fn result_envelope_accepts_both_success_and_iserror_shapes() {
732 let (m, cm) = three_input_manifest_and_map();
733 let schema = output_schema_for_manifest(&m, &cm);
734 assert_eq!(schema["properties"]["isError"]["type"], "boolean");
736 assert_eq!(schema["properties"]["code"]["type"], "string");
737 assert_eq!(schema["properties"]["reason"]["type"], "string");
738 assert_eq!(schema["additionalProperties"], true);
740 assert_eq!(schema["required"], json!(["provenance"]));
742 }
743
744 #[test]
745 fn overrides_advertise_variable_tier_keys() {
746 let (m, cm) = three_input_manifest_and_map();
749 let schema = input_schema_for_manifest(&m, &cm);
750 let override_props = &schema["properties"]["overrides"]["properties"];
751 let props = override_props
752 .as_object()
753 .expect("overrides.properties is an object");
754 for key in ["1_Inputs!B2", "1_Inputs!B3", "1_Inputs!B4"] {
758 assert!(
759 props.contains_key(key),
760 "overrides advertises the variable-tier key `{key}` (got {props:?})"
761 );
762 assert_eq!(
763 override_props[key]["type"],
764 json!(["number", "string", "boolean", "null"]),
765 "each advertised override carries the permissive value-type union"
766 );
767 }
768 assert!(
770 !props.contains_key("3_Outputs!B2") && !props.contains_key("taxable_income"),
771 "a computed output is never an advertised override key"
772 );
773 }
774
775 #[test]
776 fn overrides_advertise_named_param_keys() {
777 let mut named = input_role("1_Inputs!B2", Dtype::Number, "Gross income", None);
780 named.name = Some("in_gross_income".to_string());
781 let m = manifest_with(vec![named, output_role("3_Outputs!B2", "Taxable income")]);
782 let cm = CellMap {
783 inputs: vec![CellEntry {
784 json_key: "gross_income".to_string(),
785 seed_coord: "1_Inputs!B2".to_string(),
786 unit: Some("USD".to_string()),
787 }],
788 tools: vec![Tool {
789 name: "calculate".to_string(),
790 description: None,
791 input_keys: Vec::new(),
792 outputs: vec![CellEntry {
793 json_key: "taxable_income".to_string(),
794 seed_coord: "3_Outputs!B2".to_string(),
795 unit: Some("USD".to_string()),
796 }],
797 oracle: std::collections::BTreeMap::new(),
798 }],
799 };
800 let schema = input_schema_for_manifest(&m, &cm);
801 let override_props = &schema["properties"]["overrides"]["properties"];
802 assert!(
803 override_props["in_gross_income"].is_object(),
804 "the named variable-tier param is advertised under its name"
805 );
806 }
807
808 #[test]
809 fn overrides_keep_open_additional_properties_for_discoverability_only() {
810 let (m, cm) = three_input_manifest_and_map();
814 let schema = input_schema_for_manifest(&m, &cm);
815 let overrides = &schema["properties"]["overrides"];
816 assert_eq!(
817 overrides["additionalProperties"],
818 json!({ "type": ["number", "string", "boolean", "null"] }),
819 "the open value-typed additionalProperties map is preserved"
820 );
821 assert!(
822 overrides["description"].as_str().is_some(),
823 "the prose override description is retained"
824 );
825 }
826
827 #[test]
828 fn provenance_schema_uses_combined_hash_never_workbook_hash() {
829 let schema = provenance_schema();
830 let props = &schema["properties"];
831 assert!(props["combined_hash"].is_object());
832 assert!(
833 props.get("workbook_hash").is_none(),
834 "the provenance schema must never carry workbook_hash (Codex HIGH #3)"
835 );
836 assert_eq!(
837 schema["required"],
838 json!(["bundle_id", "version", "combined_hash"])
839 );
840 }
841
842 #[test]
843 fn empty_input_keys_projects_full_pool() {
844 let (m, cm) = three_input_manifest_and_map();
850 let tool = &cm.tools[0];
851 assert!(
852 tool.input_keys.is_empty(),
853 "the fixture tool has no derived keys"
854 );
855 let schema = input_schema_for_tool(&m, &cm, tool);
856 let props = schema["properties"]["inputs"]["properties"]
857 .as_object()
858 .expect("inputs.properties object");
859 assert!(
860 !props.is_empty(),
861 "an empty-input_keys tool advertises the full pool, not an empty schema"
862 );
863 for key in ["gross_income", "filing_status", "deductions"] {
865 assert!(
866 props.contains_key(key),
867 "the full shared-input pool is advertised: missing {key}"
868 );
869 }
870 assert_eq!(
872 schema["properties"]["inputs"]["additionalProperties"],
873 json!(false),
874 "the strict per-tool envelope is preserved"
875 );
876 }
877
878 #[test]
879 fn populated_input_keys_projects_only_reached() {
880 let (m, mut cm) = three_input_manifest_and_map();
883 cm.tools[0].input_keys = vec!["gross_income".to_string()];
884 let schema = input_schema_for_tool(&m, &cm, &cm.tools[0]);
885 let props = schema["properties"]["inputs"]["properties"]
886 .as_object()
887 .expect("inputs.properties object");
888 assert_eq!(props.len(), 1, "only the reached key is projected");
889 assert!(props.contains_key("gross_income"));
890 assert!(!props.contains_key("deductions"));
891 }
892}