diff --git a/crates/ghostlight/src/patch.rs b/crates/ghostlight/src/patch.rs index 8ab8417..2bc4c6a 100644 --- a/crates/ghostlight/src/patch.rs +++ b/crates/ghostlight/src/patch.rs @@ -7279,8 +7279,11 @@ mod catalog_tests { .iter() .filter(|entry| matches!(entry.shape, PatchToolShape::Declare { .. })) .map(|entry| { - let declaration: Declaration = - serde_json::from_value(arguments_for(entry)).expect("the entry decodes"); + let arguments = arguments_for(entry).to_string(); + let mut patch = crate::table::decode_authoring_call(entry.name, &arguments) + .expect("the entry decodes"); + assert_eq!(patch.declarations.len(), 1, "{} decoded other than one item", entry.name); + let declaration = patch.declarations.remove(0); assert!( tool_names(&declaration).contains(&entry.name), "{} decoded a declaration its own exhaustive match does not name", @@ -7337,8 +7340,11 @@ mod catalog_tests { .iter() .filter(|entry| matches!(entry.shape, PatchToolShape::Operate { .. })) .map(|entry| { - let operation: ComponentOp = - serde_json::from_value(arguments_for(entry)).expect("the entry decodes"); + let arguments = arguments_for(entry).to_string(); + let mut patch = crate::table::decode_authoring_call(entry.name, &arguments) + .expect("the entry decodes"); + assert_eq!(patch.operations.len(), 1, "{} decoded other than one item", entry.name); + let operation = patch.operations.remove(0); assert_eq!(tool_name(&operation), entry.name); operation }) @@ -7607,28 +7613,17 @@ mod catalog_tests { serde_json::from_str(&definition.parameters_json).expect("an emitted schema is JSON") } - fn decodes( - entry: &PatchTool, - mut arguments: serde_json::Map, - ) -> Result<(), String> { - match entry.shape { - PatchToolShape::Declare { variant, fixed } => { - arguments.insert("type".into(), Value::String(variant.into())); - for (key, value) in fixed { - arguments.insert((*key).into(), Value::String((*value).into())); - } - serde_json::from_value::(Value::Object(arguments)) - .map(|_| ()) - .map_err(|error| error.to_string()) - } - PatchToolShape::Operate { variant } => { - arguments.insert("op".into(), Value::String(variant.into())); - serde_json::from_value::(Value::Object(arguments)) - .map(|_| ()) - .map_err(|error| error.to_string()) - } - PatchToolShape::Session => Ok(()), + /// Routed through the one decoder (PA.f59): this used to carry its own + /// copy of the tag-injection-then-deserialize logic `decode_authoring_call` + /// already owns. `Session` still decodes no patch item and is left to its + /// own trivial `Ok`, exactly as `decode_authoring_call` itself refuses it + /// rather than accepting it as a no-op. + fn decodes(entry: &PatchTool, arguments: serde_json::Map) -> Result<(), String> { + if matches!(entry.shape, PatchToolShape::Session) { + return Ok(()); } + let arguments = Value::Object(arguments).to_string(); + crate::table::decode_authoring_call(entry.name, &arguments).map(|_| ()) } /// The direction a model actually travels: it reads the emitted schema and diff --git a/crates/ghostlight/src/table.rs b/crates/ghostlight/src/table.rs index 73d6a70..fb2d069 100644 --- a/crates/ghostlight/src/table.rs +++ b/crates/ghostlight/src/table.rs @@ -1585,8 +1585,11 @@ mod tests { assert_eq!(error, TableError::UnknownAuthoringTool("not_a_tool".to_owned())); } - /// Mutation M7.2: if `authoring_tools` stopped refusing an unknown name, - /// this assertion is the one that would no longer hold. + /// Mutation M7.2 (PA.f59: the doc here previously described the + /// unknown-name refusal above, not this test): if `authoring_tools` + /// silently dropped a known `PATCH_TOOLS` name instead of admitting it, + /// `tools.len()` would fall short of `names.len()` and this assertion is + /// the one that would catch it. #[test] fn authoring_tools_admits_every_known_name() { let names: Vec<&str> = PATCH_TOOLS.iter().map(|entry| entry.name).collect(); @@ -1683,30 +1686,53 @@ mod tests { assert!(error.contains("declare_place"), "{error}"); } - /// Rule: the elaborator's own decode and the table's decode agree, because - /// the elaborator's arm now calls the table's function rather than + /// Rule (PA.f59): the elaborator's own decode and the table's decode + /// agree over the *whole* catalog, not just `open_route` — for every + /// non-session `PATCH_TOOLS` entry's own generated example, the + /// elaboration path (`apply_tool_call`) and the table path + /// (`decode_authoring_call`) produce the whole same `WorldPatch`, + /// because the elaborator's arm calls the table's function rather than /// carrying a second copy. #[test] - fn the_elaborator_and_the_table_share_one_decoder() { - let arguments = serde_json::json!({ - "route": {"ref": "draft", "value": "route"} - }) - .to_string(); - let mut draft = WorldPatch::default(); - let mut gaps = Vec::new(); - let mut submitted = false; - crate::elaboration::apply_tool_call( - "open_route", - &arguments, - &mut draft, - &mut gaps, - &mut submitted, - ); - let table_patch = decode_authoring_call("open_route", &arguments) - .expect("open_route decodes through the table too"); - assert!(gaps.is_empty()); - assert_eq!(draft.operations, table_patch.operations); - assert!(draft.declarations.is_empty() && table_patch.declarations.is_empty()); + fn the_elaborator_and_the_table_agree_over_every_tool() { + for entry in PATCH_TOOLS { + if matches!(entry.shape, PatchToolShape::Session) { + continue; + } + let mut object = serde_json::Map::new(); + for field in entry.fields { + object.insert(field.name.to_owned(), patch::field_example(field.kind)); + } + let arguments = Value::Object(object).to_string(); + let mut draft = WorldPatch::default(); + let mut gaps = Vec::new(); + let mut submitted = false; + crate::elaboration::apply_tool_call( + entry.name, + &arguments, + &mut draft, + &mut gaps, + &mut submitted, + ); + let table_patch = decode_authoring_call(entry.name, &arguments) + .unwrap_or_else(|error| panic!("{} did not decode through the table: {error}", entry.name)); + assert!(gaps.is_empty(), "{}: the elaborator recorded a gap: {gaps:?}", entry.name); + assert_eq!( + draft.declarations, table_patch.declarations, + "{}: the elaborator's and the table's declarations disagree", + entry.name + ); + assert_eq!( + draft.operations, table_patch.operations, + "{}: the elaborator's and the table's operations disagree", + entry.name + ); + assert_eq!( + draft.evidence, table_patch.evidence, + "{}: the elaborator's and the table's evidence disagree", + entry.name + ); + } } /// Rule: an actor tool call decodes against the subject's own currently @@ -1981,6 +2007,38 @@ mod tests { assert!(!text.contains("tool call"), "{text}"); } + /// Rule (PA.f59, mutation X6): a declaration site and an operation site + /// never trade wording. `EmptyHandle`'s `position` names a declaration; + /// `SubjectNotAtOrigin`'s `operation` names an operation; with no batch + /// map in hand, each must keep its own word ("declaration"/"operation"), + /// never the other's. + #[test] + fn describe_refusal_never_swaps_declaration_and_operation_site_wording() { + let fixture = play_fixture(); + let declaration_text = describe_refusal( + &fixture.snapshot, + None, + None, + None, + &KernelError::PatchRejected(vec![Mismatch::EmptyHandle { position: 3 }]), + ); + assert!( + declaration_text.starts_with("declaration tool call #3:"), + "{declaration_text}" + ); + let operation_text = describe_refusal( + &fixture.snapshot, + None, + None, + None, + &KernelError::PatchRejected(vec![Mismatch::SubjectNotAtOrigin { operation: 3 }]), + ); + assert!( + operation_text.starts_with("operation tool call #3:"), + "{operation_text}" + ); + } + /// Rule (PA.f56): `ActionRejected` renders the failed precondition's own /// role and what the actor's invocation actually bound it to, when /// `entry` and `invocation` are given.