diff --git a/contracts/cultnet/interop/selection-vectors.cs-written.json b/contracts/cultnet/interop/selection-vectors.cs-written.json new file mode 100644 index 00000000..a1420d9c --- /dev/null +++ b/contracts/cultnet/interop/selection-vectors.cs-written.json @@ -0,0 +1,104 @@ +{ + "vectors": [ + { + "name": "any_of_plus_ge_conjunction", + "selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHOShKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORpndlYXBvbqZudW1iZXLAhKVpbmRleKRtYXNzom9womdlpnZhbHVlc8CmbnVtYmVyoTWlY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==", + "asOf": 1, + "expectedRefusalField": null, + "expectedIds": [ + "leaf_a/a-eq" + ], + "matched": 1, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "descending_with_limit", + "selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHPApWNpdGVzwKVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8OlbGltaXQDpmN1cnNvcsA=", + "asOf": 1, + "expectedRefusalField": null, + "expectedIds": [ + "citer/citer-1", + "leaf_a/shared-3", + "leaf_b/shared-2" + ], + "matched": 3, + "hasNext": true, + "expectedEdges": [] + }, + { + "name": "gt_numeric_boundary", + "selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRtYXNzom9womd0pnZhbHVlc8CmbnVtYmVyoTGlY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==", + "asOf": 1, + "expectedRefusalField": null, + "expectedIds": [ + "leaf_a/a-lo", + "leaf_a/a-eq", + "leaf_b/b-hi" + ], + "matched": 3, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "incoming_exists_true", + "selectionMessagePackBase64": "iadzY2hlbWFzkaZsZWFmX2Gka2V5c8CmZmllbGRzwKVjaXRlc8ClY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8OqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=", + "asOf": 1, + "expectedRefusalField": null, + "expectedIds": [ + "leaf_a/a-eq" + ], + "matched": 1, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "incoming_negation", + "selectionMessagePackBase64": "iadzY2hlbWFzkaZsZWFmX2Gka2V5c8CmZmllbGRzwKVjaXRlc8ClY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8KqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=", + "asOf": 1, + "expectedRefusalField": null, + "expectedIds": [ + "leaf_a/a-lo", + "leaf_a/shared-1", + "leaf_a/shared-3" + ], + "matched": 3, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "shared_index_value_three_rows", + "selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORpnNoYXJlZKZudW1iZXLApWNpdGVzwKVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=", + "asOf": 1, + "expectedRefusalField": null, + "expectedIds": [ + "leaf_a/shared-1", + "leaf_b/shared-2", + "leaf_a/shared-3" + ], + "matched": 3, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "refuses_empty_schemas_list", + "selectionMessagePackBase64": "iadzY2hlbWFzkKRrZXlzwKZmaWVsZHPApWNpdGVzwKVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=", + "asOf": 1, + "expectedRefusalField": "schemas", + "expectedIds": [], + "matched": 0, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "refuses_blank_key_entry", + "selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkqRhLWVxoyAgIKZmaWVsZHPApWNpdGVzwKVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=", + "asOf": 1, + "expectedRefusalField": "keys", + "expectedIds": [], + "matched": 0, + "hasNext": false, + "expectedEdges": [] + } + ] +} \ No newline at end of file diff --git a/contracts/cultnet/interop/selection-vectors.rs-written.json b/contracts/cultnet/interop/selection-vectors.rs-written.json new file mode 100644 index 00000000..f1c19b63 --- /dev/null +++ b/contracts/cultnet/interop/selection-vectors.rs-written.json @@ -0,0 +1,98 @@ +{ + "vectors": [ + { + "name": "any_of_plus_ge_conjunction", + "selectionMessagePackBase64": "g6ZmaWVsZHOSg6VpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORpndlYXBvboOlaW5kZXikbWFzc6JvcKJnZaZudW1iZXKhNapwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==", + "asOf": 1, + "expectedIds": [ + "leaf_a/a-eq" + ], + "matched": 1, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "descending_with_limit", + "selectionMessagePackBase64": "g6pwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nw6VsaW1pdAM=", + "asOf": 1, + "expectedIds": [ + "citer/citer-1", + "leaf_a/shared-3", + "leaf_b/shared-2" + ], + "matched": 3, + "hasNext": true, + "expectedEdges": [] + }, + { + "name": "gt_numeric_boundary", + "selectionMessagePackBase64": "g6ZmaWVsZHORg6VpbmRleKRtYXNzom9womd0pm51bWJlcqExqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfC", + "asOf": 1, + "expectedIds": [ + "leaf_a/a-lo", + "leaf_a/a-eq", + "leaf_b/b-hi" + ], + "matched": 3, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "incoming_exists_true", + "selectionMessagePackBase64": "hKdzY2hlbWFzkaZsZWFmX2GlY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8OqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=", + "asOf": 1, + "expectedIds": [ + "leaf_a/a-eq" + ], + "matched": 1, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "incoming_negation", + "selectionMessagePackBase64": "hKdzY2hlbWFzkaZsZWFmX2GlY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8KqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=", + "asOf": 1, + "expectedIds": [ + "leaf_a/a-lo", + "leaf_a/shared-1", + "leaf_a/shared-3" + ], + "matched": 3, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "shared_index_value_three_rows", + "selectionMessagePackBase64": "g6ZmaWVsZHORg6VpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORpnNoYXJlZKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==", + "asOf": 1, + "expectedIds": [ + "leaf_a/shared-1", + "leaf_b/shared-2", + "leaf_a/shared-3" + ], + "matched": 3, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "refuses_empty_schemas_list", + "selectionMessagePackBase64": "g6dzY2hlbWFzkKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==", + "asOf": 1, + "expectedRefusalField": "schemas", + "expectedIds": [], + "matched": 0, + "hasNext": false, + "expectedEdges": [] + }, + { + "name": "refuses_blank_key_entry", + "selectionMessagePackBase64": "g6RrZXlzkqRhLWVxoyAgIKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==", + "asOf": 1, + "expectedRefusalField": "keys", + "expectedIds": [], + "matched": 0, + "hasNext": false, + "expectedEdges": [] + } + ] +} \ No newline at end of file diff --git a/packages/cultnet-rs/src/selection.rs b/packages/cultnet-rs/src/selection.rs index b4e74bf6..58912e56 100644 --- a/packages/cultnet-rs/src/selection.rs +++ b/packages/cultnet-rs/src/selection.rs @@ -774,24 +774,27 @@ pub fn validate(selection: &Selection, row_set: &impl RowSet) -> Result<(), Sele // `null` (Rust: `None`) means no filter, everywhere - an empty-or-blank list is never // silently treated as "no filter" by the evaluator (that was CultNetSelectionEvaluator.cs's // pre-fix bug, section 2's S2-3 finding, and this Rust evaluator never had it). + // Field name matches the C# reference exactly (568e8e3): plain "schemas"/"keys", not an + // indexed "schemas[i]" - CultNetSelectionInvalidException("schemas", schema, ...) names the + // list, not the position, and the parity vectors compare this string. if let Some(schemas) = &selection.schemas { - for (index, schema) in schemas.iter().enumerate() { + for schema in schemas { if schema.trim().is_empty() { return Err(SelectionInvalid::new( - format!("schemas[{index}]"), + "schemas", Some(schema.clone()), - format!("schemas[{index}] is empty or whitespace."), + "selection.schemas carries an empty or whitespace entry.", )); } } } if let Some(keys) = &selection.keys { - for (index, key) in keys.iter().enumerate() { + for key in keys { if key.trim().is_empty() { return Err(SelectionInvalid::new( - format!("keys[{index}]"), + "keys", Some(key.clone()), - format!("keys[{index}] is empty or whitespace."), + "selection.keys carries an empty or whitespace entry.", )); } } diff --git a/packages/cultnet-rs/src/snapshot_query.rs b/packages/cultnet-rs/src/snapshot_query.rs index bde654d0..9f969433 100644 --- a/packages/cultnet-rs/src/snapshot_query.rs +++ b/packages/cultnet-rs/src/snapshot_query.rs @@ -367,15 +367,20 @@ fn require_non_empty(value: &str, field: &str) -> Result<()> { Ok(()) } -/// v0's own cleaning (Self's ruling, 2026-09-22): an absent list, an empty list, or a list made -/// only of blank entries all lower to `None` (no filter) - a v0 compatibility rule the lowering -/// owns, not a meaning the door or the evaluator carries for v1's own lists (those refuse `[]` -/// and any blank entry outright, in `selection::validate`). +/// v0's own cleaning, matching the reference's `CultNetV0SelectionLowering.Lower` exactly +/// (568e8e3): an absent list, an empty list, or a list made only of blank entries all lower to +/// `None` (no filter); a mixed list keeps its non-blank entries, deduplicated (order-preserving, +/// first occurrence wins - `Distinct(StringComparer.Ordinal)`'s behaviour). A v0 compatibility +/// rule the lowering owns, not a meaning the door or the evaluator carries for v1's own lists +/// (those refuse `[]` and any blank entry outright, in `selection::validate`). fn lower_v0_list(list: &Option>) -> Option> { - match list { - Some(values) if !values.is_empty() && values.iter().any(|value| !value.trim().is_empty()) => { - Some(values.clone()) - } - _ => None, - } + let Some(values) = list else { return None }; + let mut seen = std::collections::HashSet::new(); + let filtered: Vec = values + .iter() + .filter(|value| !value.trim().is_empty()) + .filter(|value| seen.insert(value.as_str())) + .cloned() + .collect(); + if filtered.is_empty() { None } else { Some(filtered) } } diff --git a/packages/cultnet-rs/tests/cultnet.rs b/packages/cultnet-rs/tests/cultnet.rs index 478682fa..d19536ba 100644 --- a/packages/cultnet-rs/tests/cultnet.rs +++ b/packages/cultnet-rs/tests/cultnet.rs @@ -2098,6 +2098,27 @@ fn serve_read_only_raw_snapshot_lowers_an_empty_or_blank_v0_schema_list_to_no_fi ); } + // A mixed list keeps its non-blank entries, deduplicated (matches + // CultNetV0SelectionLowering.Lower exactly, 568e8e3: Distinct(StringComparer.Ordinal)). + let mixed_request = CultNetMessage::SnapshotRequest { + message_id: "req-2".to_string(), + schema_ids: Some(vec![ + "ghostlight.agent-state".to_string(), + " ".to_string(), + "ghostlight.agent-state".to_string(), + ]), + record_keys: None, + }; + let mixed_response = serve_read_only_raw_snapshot(®istry, &policy, &source, &mixed_request)?; + let CultNetMessage::SnapshotResponseRaw { documents, .. } = mixed_response else { + panic!("expected a raw snapshot response"); + }; + assert_eq!( + documents.len(), + 2, + "a mixed list keeps its one distinct non-blank schema id, still selecting both rows of that schema" + ); + Ok(()) } diff --git a/packages/cultnet-rs/tests/selection.rs b/packages/cultnet-rs/tests/selection.rs index 47af21de..ffbad331 100644 --- a/packages/cultnet-rs/tests/selection.rs +++ b/packages/cultnet-rs/tests/selection.rs @@ -270,6 +270,35 @@ fn hops_one_edge_by_declared_role() { assert_eq!(evaluation.edges[0].role, "Design"); } +// S6, isolated: a citer with two references at the *same* target under different roles - the +// role filter must exclude the one the caller did not ask for. base_rows()'s citer-1 only ever +// carries one reference, so a mutant that drops the role filter entirely is invisible there (it +// has nothing else to wrongly match); this fixture gives it something to wrongly match. +#[test] +fn cites_role_excludes_a_second_reference_at_the_same_target() { + let rows = vec![ + FixtureRow::leaf("leaf_a", "a-eq", 1, "weapon", "5"), + FixtureRow::citer( + "double-citer", + 2, + vec![ + ("Design".to_string(), rr("leaf_a", "a-eq"), None), + ("OtherRole".to_string(), rr("leaf_a", "a-eq"), None), + ], + ), + ]; + let selection = Selection { + cites: Some(Citation { + target: rr("leaf_a", "a-eq"), + role: Some("Design".into()), + }), + ..Selection::default() + }; + let evaluation = select(&FixtureRowSet, &rows, &selection, 1).unwrap(); + assert_eq!(evaluation.edges.len(), 1, "OtherRole's reference must not also match"); + assert_eq!(evaluation.edges[0].role, "Design"); +} + // S7: cited { exists } is the one negation, and the two directions answer opposite sets. #[test] fn cited_exists_is_the_one_negation() { @@ -484,13 +513,13 @@ fn validation_refuses_a_blank_entry_in_schemas_or_keys() { schemas: Some(vec!["leaf_a".into(), " ".into()]), ..Selection::default() }; - assert_eq!(validate(&blank_schema, &FixtureRowSet).unwrap_err().field, "schemas[1]"); + assert_eq!(validate(&blank_schema, &FixtureRowSet).unwrap_err().field, "schemas"); let blank_key = Selection { keys: Some(vec!["".into()]), ..Selection::default() }; - assert_eq!(validate(&blank_key, &FixtureRowSet).unwrap_err().field, "keys[0]"); + assert_eq!(validate(&blank_key, &FixtureRowSet).unwrap_err().field, "keys"); } // Q-J: the door refuses every named non-canonical spelling of a comparison number. @@ -854,7 +883,7 @@ fn write_selection_vectors_for_the_reference() { keys: Some(vec!["a-eq".into(), " ".into()]), ..Selection::default() }, - "keys[1]", + "keys", ), ]; for (name, selection, expected_field) in refusal_cases { diff --git a/scripts/mutate-cultnet-selection-rust.entries.ps1 b/scripts/mutate-cultnet-selection-rust.entries.ps1 index e2770e21..b38c2764 100644 --- a/scripts/mutate-cultnet-selection-rust.entries.ps1 +++ b/scripts/mutate-cultnet-selection-rust.entries.ps1 @@ -184,6 +184,26 @@ }" New = 'return value.to_string();' }, + @{ + Id = 'RS-S2-Revert' + Rule = 'S2: an any_of predicate matches row-value membership, not "any value at all".' + Test = 'conjoins_any_of_and_comparison_predicates' + Command = 'cargo test --test selection' + File = 'src/selection.rs' + Old = 'if !values.iter().any(|v| wanted.iter().any(|w| w == v)) {' + New = 'if false {' + }, + @{ + Id = 'RS-S6-Revert' + Rule = 'S6: cites.role, when present, filters which declared reference the hop follows.' + Test = 'cites_role_excludes_a_second_reference_at_the_same_target' + Command = 'cargo test --test selection' + File = 'src/selection.rs' + Old = "if let Some(wanted_role) = &citation.role + && *wanted_role != role + {" + New = 'if false {' + }, @{ Id = 'RS-EmptySchemas-Revert' Rule = 'S1: an empty (but present) schemas list is refused, never answered as an empty page.' @@ -225,9 +245,16 @@ Id = 'RS-V0Lowering-Revert' Rule = 'v0''s own cleaning: an empty or all-blank v0 list lowers to null (no filter), not to a list the door or the evaluator would have to refuse or misread.' Test = 'serve_read_only_raw_snapshot_lowers_an_empty_or_blank_v0_schema_list_to_no_filter' - Command = 'cargo test --test cultnet' + # Narrowed to this test alone, not the whole tests/cultnet.rs binary: that binary carries + # reactive_document_coalesces_direct_same_schema_alias_member_writes, a pre-existing test + # unrelated to this campaign that fails under a full-binary run (both parallel and + # RUST_TEST_THREADS=1) but passes every time in isolation - state pollution from another + # test in that binary, not a correctness question this entry is about. Filtering M0 and + # the entry run to this test's own name keeps this suite from depending on that binary's + # unrelated health; the flake itself is a separate finding, not fixed here. + Command = 'cargo test --test cultnet serve_read_only_raw_snapshot' File = 'src/snapshot_query.rs' - Old = 'Some(values) if !values.is_empty() && values.iter().any(|value| !value.trim().is_empty()) => {' - New = 'Some(values) => {' + Old = 'if filtered.is_empty() { None } else { Some(filtered) }' + New = 'Some(filtered)' } ) diff --git a/tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs b/tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs index 261c0d12..00de1c46 100644 --- a/tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs +++ b/tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs @@ -89,7 +89,7 @@ namespace GameCult.Networking.Tests private static IEnumerable<(string Name, CultNetSelection Selection, string ExpectedField)> RefusalCases() { yield return ("refuses_empty_schemas_list", new CultNetSelection { Schemas = Array.Empty() }, "schemas"); - yield return ("refuses_blank_key_entry", new CultNetSelection { Keys = new[] { "a-eq", " " } }, "keys[1]"); + yield return ("refuses_blank_key_entry", new CultNetSelection { Keys = new[] { "a-eq", " " } }, "keys"); } private static string RowId(CultNetSelectionEvaluator.Row row) => $"{row.Descriptor.SchemaName}/{row.Key.Value}";