Widen the shared-decoder test to every tool and route two more copies through it (PA.f59)

`the_elaborator_and_the_table_share_one_decoder` proved agreement between
`apply_tool_call` and `decode_authoring_call` for exactly one tool,
`open_route`. Renamed to `the_elaborator_and_the_table_agree_over_every_tool`
and widened to every non-session `PATCH_TOOLS` entry's own generated
example, asserting the whole `WorldPatch` — declarations, operations, and
evidence — agrees, not only operations.

patch.rs's `decodes` carried its own copy of the tag-injection-then-
deserialize logic `decode_authoring_call` already owns; it now builds the
same arguments string and calls `decode_authoring_call` directly (`Session`
still short-circuits to `Ok(())`, matching `decode_authoring_call`'s own
refusal of that shape). `every_declaration` and `every_component_op`
similarly deserialized `arguments_for`'s output straight into `Declaration`/
`ComponentOp`, bypassing the table's decoder entirely; both now decode
through `decode_authoring_call` and pull the single declaration or operation
out of its `WorldPatch`.

Corrected `authoring_tools_admits_every_known_name`'s doc comment, which
described the unknown-name-refusal test above it rather than its own
assertion.

Added `describe_refusal_never_swaps_declaration_and_operation_site_wording`
(mutation X6): a declaration site (`EmptyHandle`'s `position`) and an
operation site (`SubjectNotAtOrigin`'s `operation`) render "declaration tool
call #N" and "operation tool call #N" respectively, and must never trade
words.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-22 13:12:57 +02:00
commit 2035fe8722
2 changed files with 102 additions and 49 deletions

View file

@ -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<String, Value>,
) -> 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::<Declaration>(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::<ComponentOp>(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<String, Value>) -> 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

View file

@ -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.