From 3d1c3035229ce78366fe4fe630293b5503f077d5 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Mon, 24 Aug 2026 00:03:12 +1000 Subject: [PATCH 1/2] feat(ai): rank capability discovery by operation shape --- Cargo.lock | 4 +- Cargo.toml | 2 +- .../graphql-orm-ai-tool-profiles/CHANGELOG.md | 21 ++ .../graphql-orm-ai-tool-profiles/Cargo.toml | 2 +- .../graphql-orm-ai-tool-profiles/MIGRATION.md | 17 + crates/graphql-orm-ai-tool-profiles/README.md | 17 +- .../src/capability_index.rs | 321 +++++++++++++++--- .../src/query_plans.rs | 53 +++ crates/graphql-orm-ai/CHANGELOG.md | 18 + crates/graphql-orm-ai/Cargo.toml | 2 +- crates/graphql-orm-ai/MIGRATION.md | 12 + crates/graphql-orm-ai/README.md | 4 +- .../capability-discovery-and-execution.md | 13 +- .../docs/implementation-status.md | 6 +- .../graphql-orm-ai/src/capability_delivery.rs | 58 ++++ docs/reference/workspace-packages.md | 4 +- 16 files changed, 483 insertions(+), 71 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 27ced29f..cf0755fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3104,7 +3104,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai" -version = "0.93.2" +version = "0.94.0" dependencies = [ "agql-auth", "async-graphql", @@ -3136,7 +3136,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai-tool-profiles" -version = "0.9.0" +version = "0.10.0" dependencies = [ "async-graphql", "async-graphql-parser", diff --git a/Cargo.toml b/Cargo.toml index f207195b..ba31b1a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,7 @@ futures = "0.3" getrandom = "0.3" graphql-composition = "=0.12.2" graphql-orm = { path = "crates/graphql-orm", version = "0.26.0", default-features = false } -graphql-orm-ai-tool-profiles = { path = "crates/graphql-orm-ai-tool-profiles", version = "0.9.0" } +graphql-orm-ai-tool-profiles = { path = "crates/graphql-orm-ai-tool-profiles", version = "0.10.0" } graphql-orm-backup = { path = "crates/graphql-orm-backup", version = "0.7.1", default-features = false } graphql-orm-operation-catalog = { path = "crates/graphql-orm-operation-catalog", version = "0.3.0" } graphql-orm-router-protocol = { path = "crates/graphql-orm-router-protocol", version = "0.2.1" } diff --git a/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md b/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md index 7982858f..f427dcdf 100644 --- a/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md +++ b/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md @@ -10,6 +10,27 @@ supersedes: [] # Changelog +## [0.10.0] - 2026-08-23 + +### Added + +- Capability indexes now carry conservative compiler-owned maximum root and + total result-record costs and whether an explicit root bound is required. + +### Changed + +- Discovery selects an inferred mechanical list, details, search, keyset, or + aggregate shape before ranking entity, execution target, namespace, and + lexical relevance. +- The canonical capability-index contract version is now `2`; index and set + fingerprints intentionally change. + +### Security + +- Shape and cost metadata remain descriptive only. Current host policy, + short-lived load bindings, compiler validation, and resolver authorization + remain mandatory. + ## [0.9.0] - 2026-08-22 ### Added diff --git a/crates/graphql-orm-ai-tool-profiles/Cargo.toml b/crates/graphql-orm-ai-tool-profiles/Cargo.toml index 6ecef21a..763b1a16 100644 --- a/crates/graphql-orm-ai-tool-profiles/Cargo.toml +++ b/crates/graphql-orm-ai-tool-profiles/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-ai-tool-profiles" -version = "0.9.0" +version = "0.10.0" edition = "2024" authors = ["Toby Martin "] description = "Backend-neutral GraphQL AI tool profile compiler and manifest contracts" diff --git a/crates/graphql-orm-ai-tool-profiles/MIGRATION.md b/crates/graphql-orm-ai-tool-profiles/MIGRATION.md index 957aaeca..e1286094 100644 --- a/crates/graphql-orm-ai-tool-profiles/MIGRATION.md +++ b/crates/graphql-orm-ai-tool-profiles/MIGRATION.md @@ -10,6 +10,23 @@ supersedes: [] # Migration Guide +## 0.9.0 to 0.10.0: shape-aware discovery and record-cost metadata + +Adopt `graphql-orm-ai-tool-profiles` 0.10.0 with `graphql-orm-ai` 0.94.0 from +one reviewed full monorepo revision. Recompile every capability index and +refresh retained provider-session bindings: the canonical index contract is +now version `2`, and the added result-record cost metadata changes entry, +index, and index-set fingerprints. + +Search callers keep the same request type. List/details/search/keyset/aggregate +language now selects the matching mechanical operation shape before entity, +namespace, and lexical ranking. Fixed-broker descriptions add +`resultRecordCost` with compiler-owned `maximumRootRecords`, +`maximumTotalRecords`, and `rootBoundRequired` fields. + +There is no database, data, GraphQL SDL, protected-content, backup, restore, or +persistent AI schema-module migration. Discovery remains non-authoritative. + ## 0.8.0 to 0.9.0: proof-bearing stateless native-item refusal Adopt `graphql-orm-ai-tool-profiles` 0.9.0 with `graphql-orm-ai` 0.86.0 from diff --git a/crates/graphql-orm-ai-tool-profiles/README.md b/crates/graphql-orm-ai-tool-profiles/README.md index 6ceb56af..3e73ddeb 100644 --- a/crates/graphql-orm-ai-tool-profiles/README.md +++ b/crates/graphql-orm-ai-tool-profiles/README.md @@ -24,7 +24,7 @@ are separate runtime decisions and must remain default-deny. ```toml [dependencies] -graphql-orm-ai-tool-profiles = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.9.0" } +graphql-orm-ai-tool-profiles = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.10.0" } serde_json = "1" ``` @@ -121,12 +121,17 @@ semantic summaries and exact fingerprints. It intentionally contains no JSON Schema, GraphQL document/SDL, database name, resolver URL, policy expression, credential, authority or secret/hidden field. -`AiCapabilityIndex::search` provides bounded deterministic lexical discovery -with exact namespace/kind/entity filters and stable ID tie-breaking. Search +`AiCapabilityIndex::search` provides bounded deterministic discovery with +exact namespace/kind/entity filters and stable ID tie-breaking. A declared +list, details, search, keyset, or aggregate intent first selects the matching +compiler-owned operation shape; public entity, execution-target, and namespace +relevance rank next, and lexical description matches break ties within that +shape. Search returns exact candidate/index/schema/semantic/target-policy fingerprints but -grants no authority. The runtime package owns current-principal rehydration, -policy reapplication, short-lived loaded bindings and ordinary resolver -execution. +grants no authority. Each entry also carries conservative compiler-owned root +and total result-record bounds for later planning. The runtime package owns +current-principal rehydration, policy reapplication, short-lived loaded +bindings and ordinary resolver execution. Opt-in aggregate roots use the same catalogue and a fixed result projection. Their filters, grouping, metrics, operators, and group limits remain typed and diff --git a/crates/graphql-orm-ai-tool-profiles/src/capability_index.rs b/crates/graphql-orm-ai-tool-profiles/src/capability_index.rs index 56589951..795ff69c 100644 --- a/crates/graphql-orm-ai-tool-profiles/src/capability_index.rs +++ b/crates/graphql-orm-ai-tool-profiles/src/capability_index.rs @@ -22,13 +22,13 @@ use sha2::{Digest, Sha256}; use crate::{ AiApprovalRule, AiError, AiGraphqlMutationCapabilityCatalog, AiGraphqlQueryCapabilityCatalog, - AiGraphqlSubscriptionCapabilityCatalog, AiToolDescriptor, AiToolId, AiToolOperationDomain, - AiToolOperationKind, AiToolRisk, DataClassification, GraphqlExecutionTargetId, - canonical_json::canonical_json_bytes, + AiGraphqlResultRecordCostEstimate, AiGraphqlSubscriptionCapabilityCatalog, AiToolDescriptor, + AiToolId, AiToolOperationDomain, AiToolOperationKind, AiToolRisk, DataClassification, + GraphqlExecutionTargetId, canonical_json::canonical_json_bytes, }; /// Current compact capability-index contract version. -pub const AI_CAPABILITY_INDEX_VERSION: u16 = 1; +pub const AI_CAPABILITY_INDEX_VERSION: u16 = 2; /// Current deterministic multi-target capability-index-set contract version. pub const AI_CAPABILITY_INDEX_SET_VERSION: u16 = 1; @@ -238,6 +238,8 @@ pub struct AiCapabilityIndexEntry { pub result_classification: DataClassification, /// Bounded result description. pub result_description: String, + /// Conservative compiler-owned result-record planning bounds. + pub result_record_cost: AiGraphqlResultRecordCostEstimate, /// Risk classification. pub risk: AiToolRisk, /// Approval classification. @@ -352,6 +354,7 @@ impl AiCapabilityIndex { capability.id().clone(), AiCapabilityKind::GeneratedQuery, capability.fingerprint(), + capability.result_record_cost_estimate(), catalogue.fingerprint(), operation, &entities, @@ -373,6 +376,7 @@ impl AiCapabilityIndex { capability.id().clone(), AiCapabilityKind::GeneratedMutation, capability.fingerprint(), + capability.result_record_cost_estimate(), catalogue.fingerprint(), operation, &entities, @@ -394,6 +398,7 @@ impl AiCapabilityIndex { capability.id().clone(), AiCapabilityKind::GeneratedSubscription, capability.fingerprint(), + capability.result_record_cost_estimate(), catalogue.fingerprint(), operation, &entities, @@ -496,27 +501,14 @@ impl AiCapabilityIndex { query: &AiCapabilitySearchQuery, ) -> Result { query.validate(self.limits.maximum_search_results)?; - let terms = search_terms(&query.text); - let mut ranked = self - .entries - .values() - .filter(|entry| entry_matches_query(entry, query)) - .filter_map(|entry| { - let score = search_score(entry, &terms); - (score > 0).then_some((score, entry)) - }) - .collect::>(); - ranked.sort_by(|left, right| { - right - .0 - .cmp(&left.0) - .then_with(|| left.1.id.cmp(&right.1.id)) - }); - let candidates = ranked - .into_iter() - .take(usize::from(query.maximum_results)) - .map(|(_, entry)| search_candidate(entry)) - .collect(); + let candidates = rank_entries( + self.entries.values().map(|entry| (&self.target_id, entry)), + query, + ) + .into_iter() + .take(usize::from(query.maximum_results)) + .map(|(_, entry)| search_candidate(entry)) + .collect(); Ok(AiCapabilitySearchResult { index_fingerprint: self.fingerprint.clone(), schema_fingerprint: self.schema_fingerprint.clone(), @@ -705,28 +697,19 @@ impl AiCapabilityIndexSet { query: &AiCapabilitySearchQuery, ) -> Result { query.validate(self.maximum_search_results)?; - let terms = search_terms(&query.text); - let mut ranked = self - .indexes - .values() - .flat_map(|index| index.entries.values()) - .filter(|entry| entry_matches_query(entry, query)) - .filter_map(|entry| { - let score = search_score(entry, &terms); - (score > 0).then_some((score, entry)) - }) - .collect::>(); - ranked.sort_by(|left, right| { - right - .0 - .cmp(&left.0) - .then_with(|| left.1.id.cmp(&right.1.id)) - }); - let candidates = ranked - .into_iter() - .take(usize::from(query.maximum_results)) - .map(|(_, entry)| search_candidate(entry)) - .collect(); + let candidates = rank_entries( + self.indexes.values().flat_map(|index| { + index + .entries + .values() + .map(|entry| (&index.target_id, entry)) + }), + query, + ) + .into_iter() + .take(usize::from(query.maximum_results)) + .map(|(_, entry)| search_candidate(entry)) + .collect(); Ok(AiCapabilityIndexSetSearchResult { index_set_fingerprint: self.fingerprint.clone(), candidates, @@ -847,6 +830,42 @@ fn entry_matches_query(entry: &AiCapabilityIndexEntry, query: &AiCapabilitySearc }) } +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct SearchRank { + shape: u8, + entity: u16, + target: u16, + namespace: u16, + lexical: u64, +} + +fn rank_entries<'a>( + entries: impl IntoIterator, + query: &AiCapabilitySearchQuery, +) -> Vec<(SearchRank, &'a AiCapabilityIndexEntry)> { + let terms = search_terms(&query.text); + let shape_intent = search_shape_intent(&terms); + let mut ranked = entries + .into_iter() + .filter(|(_, entry)| entry_matches_query(entry, query)) + .filter_map(|(target_id, entry)| { + let rank = search_rank(target_id, entry, &terms, shape_intent); + (rank.entity > 0 || rank.target > 0 || rank.namespace > 0 || rank.lexical > 0) + .then_some((rank, entry)) + }) + .collect::>(); + if shape_intent.is_some() && ranked.iter().any(|(rank, _)| rank.shape > 0) { + ranked.retain(|(rank, _)| rank.shape > 0); + } + ranked.sort_by(|left, right| { + right + .0 + .cmp(&left.0) + .then_with(|| left.1.id.cmp(&right.1.id)) + }); + ranked +} + fn search_candidate(entry: &AiCapabilityIndexEntry) -> AiCapabilitySearchCandidate { AiCapabilitySearchCandidate { id: entry.id.clone(), @@ -867,6 +886,7 @@ fn generated_entry( id: AiToolId, kind: AiCapabilityKind, capability_fingerprint: &str, + result_record_cost: AiGraphqlResultRecordCostEstimate, catalogue_fingerprint: &str, operation: &GraphqlSemanticOperationDescriptor, entities: &BTreeMap<&str, &graphql_orm_operation_catalog::GraphqlEntitySemanticMetadata>, @@ -921,6 +941,7 @@ fn generated_entry( aggregate_features, result_classification, result_description: generated_result_description(operation), + result_record_cost, risk: generated_risk(kind, operation), approval: generated_approval(kind, operation), capability_fingerprint: capability_fingerprint.to_owned(), @@ -1006,6 +1027,11 @@ fn static_entry( "Bounded result: at most {} records and {} bytes.", descriptor.maximum_result_records, descriptor.maximum_result_bytes ), + result_record_cost: AiGraphqlResultRecordCostEstimate { + maximum_root_records: descriptor.maximum_result_records, + maximum_total_records: descriptor.maximum_result_records, + root_bound_required: false, + }, risk: descriptor.risk, approval: descriptor.approval, capability_fingerprint: descriptor.fingerprint.clone(), @@ -1327,7 +1353,12 @@ fn aggregate_name(operator: GraphqlAggregateOperator) -> &'static str { } } -fn search_score(entry: &AiCapabilityIndexEntry, terms: &BTreeSet) -> u64 { +fn search_rank( + target_id: &GraphqlExecutionTargetId, + entry: &AiCapabilityIndexEntry, + terms: &BTreeSet, + shape_intent: Option, +) -> SearchRank { let mut score = 0_u64; let id = search_terms(entry.id.as_str()); let name = search_terms(&entry.name); @@ -1337,6 +1368,7 @@ fn search_score(entry: &AiCapabilityIndexEntry, terms: &BTreeSet) -> u64 .as_deref() .map(search_terms) .unwrap_or_default(); + let target = search_terms(target_id.as_str()); let description = search_terms(&entry.description); let relationships = entry .relationships @@ -1354,6 +1386,9 @@ fn search_score(entry: &AiCapabilityIndexEntry, terms: &BTreeSet) -> u64 .flat_map(|field| search_terms(&format!("{} {}", field.name, field.description))) .collect::>(); for term in terms { + if search_shape_term(term) && entry.operation_shape != AiCapabilityOperationShape::Custom { + continue; + } score += u64::from(id.contains(term)) * 10; score += u64::from(name.contains(term)) * 9; score += u64::from(operation.contains(term)) * 8; @@ -1361,9 +1396,70 @@ fn search_score(entry: &AiCapabilityIndexEntry, terms: &BTreeSet) -> u64 score += u64::from(relationships.contains(term)) * 7; score += u64::from(fields.contains(term)) * 4; score += u64::from(description.contains(term)) * 3; - score += u64::from(semantic_key(shape_name(entry.operation_shape)) == *term) * 6; } - score + SearchRank { + shape: u8::from(shape_intent == Some(entry.operation_shape)), + entity: u16::try_from(terms.intersection(&entity).count()).unwrap_or(u16::MAX), + target: u16::try_from(terms.intersection(&target).count()).unwrap_or(u16::MAX), + namespace: u16::try_from(terms.intersection(&search_terms(&entry.namespace)).count()) + .unwrap_or(u16::MAX), + lexical: score, + } +} + +fn search_shape_intent(terms: &BTreeSet) -> Option { + if terms.contains("count") + || terms.contains("aggregate") + || terms.contains("total") + || terms.contains("number") + || terms.contains("how") && terms.contains("many") + { + Some(AiCapabilityOperationShape::Aggregate) + } else if terms.contains("detail") || terms.contains("single") || terms.contains("specific") { + Some(AiCapabilityOperationShape::Details) + } else if terms.contains("search") + || terms.contains("find") + || terms.contains("match") + || terms.contains("lookup") + { + Some(AiCapabilityOperationShape::Search) + } else if terms.contains("keyset") || terms.contains("page") || terms.contains("next") { + Some(AiCapabilityOperationShape::KeysetList) + } else if terms.contains("list") + || terms.contains("all") + || terms.contains("latest") + || terms.contains("recent") + { + Some(AiCapabilityOperationShape::List) + } else { + None + } +} + +fn search_shape_term(term: &str) -> bool { + matches!( + term, + "count" + | "aggregate" + | "total" + | "number" + | "how" + | "many" + | "detail" + | "single" + | "specific" + | "search" + | "find" + | "match" + | "lookup" + | "keyset" + | "page" + | "next" + | "list" + | "all" + | "latest" + | "recent" + ) } fn search_terms(value: &str) -> BTreeSet { @@ -1443,6 +1539,95 @@ mod tests { .with_maximum_classification(DataClassification::Public) } + fn synthetic_shape_index() -> AiCapabilityIndex { + let semantic = semantic_catalogue(); + let target_id = GraphqlExecutionTargetId::parse("synthetic-application").expect("target"); + let mut entries = BTreeMap::new(); + for entity_index in 0..101 { + let entity = format!("SyntheticEntity{entity_index:03}"); + for shape in [ + AiCapabilityOperationShape::List, + AiCapabilityOperationShape::Details, + AiCapabilityOperationShape::Aggregate, + AiCapabilityOperationShape::Search, + ] { + let shape_id = match shape { + AiCapabilityOperationShape::List => "list", + AiCapabilityOperationShape::Details => "details", + AiCapabilityOperationShape::Aggregate => "aggregate", + AiCapabilityOperationShape::Search => "search", + _ => unreachable!("synthetic shape is fixed"), + }; + let id = format!("inventory.synthetic_{entity_index:03}_{shape_id}"); + let mut entry = static_entry( + descriptor( + &id, + &format!("Use {entity} records for an operator investigation."), + 0, + ), + &target_id, + "synthetic-schema-v1", + &semantic.fingerprint, + "synthetic-policy-v1", + ) + .expect("synthetic entry"); + entry.kind = AiCapabilityKind::GeneratedQuery; + entry.name = format!("{} {entity}", shape_name(shape)); + entry.entity_name = Some(entity.clone()); + entry.operation_name = format!("Synthetic{entity_index:03}{shape_id}"); + entry.operation_shape = shape; + entry.fingerprint = entry_fingerprint(&entry); + assert!(entries.insert(entry.id.clone(), entry).is_none()); + } + } + AiCapabilityIndex { + version: AI_CAPABILITY_INDEX_VERSION, + target_id, + schema_fingerprint: "synthetic-schema-v1".to_owned(), + semantic_catalogue_fingerprint: semantic.fingerprint, + target_policy_fingerprint: "synthetic-policy-v1".to_owned(), + entries, + fingerprint: "a".repeat(64), + limits: AiCapabilityIndexLimits::default(), + } + } + + #[test] + fn four_hundred_capabilities_rank_mechanical_shape_before_lexical_ties() { + let index = synthetic_shape_index(); + assert_eq!(index.entries().len(), 404); + for (text, expected) in [ + ( + "list SyntheticEntity042 records", + AiCapabilityOperationShape::List, + ), + ( + "details of SyntheticEntity042", + AiCapabilityOperationShape::Details, + ), + ( + "how many SyntheticEntity042 records", + AiCapabilityOperationShape::Aggregate, + ), + ] { + let result = index + .search(&AiCapabilitySearchQuery { + text: text.to_owned(), + namespace: None, + kind: Some(AiCapabilityKind::GeneratedQuery), + entity_or_operation: None, + maximum_results: 1, + }) + .expect("shape-aware search"); + assert_eq!(result.candidates.len(), 1); + assert_eq!(result.candidates[0].operation_shape, expected); + assert_eq!( + result.candidates[0].entity_name.as_deref(), + Some("SyntheticEntity042") + ); + } + } + #[test] fn large_definition_universe_has_a_small_complete_discovery_surface() { let semantic = semantic_catalogue(); @@ -1647,6 +1832,42 @@ mod tests { ); } + #[test] + fn index_set_search_ranks_execution_target_before_lexical_ties() { + let semantic = semantic_catalogue(); + let compile = |target: &str, id: &str| { + Arc::new( + AiCapabilityIndex::compile( + GraphqlExecutionTargetId::parse(target).expect("target"), + format!("schema-{target}"), + &semantic, + None, + None, + None, + [descriptor(id, "Inspect matching operational records.", 0)], + format!("policy-{target}"), + AiCapabilityIndexLimits::default(), + ) + .expect("index"), + ) + }; + let set = AiCapabilityIndexSet::compile([ + compile("alpha-service", "inventory.records"), + compile("beta-service", "workforce.records"), + ]) + .expect("set"); + let result = set + .search(&AiCapabilitySearchQuery { + text: "beta service operational records".to_owned(), + namespace: None, + kind: None, + entity_or_operation: None, + maximum_results: 1, + }) + .expect("search"); + assert_eq!(result.candidates[0].id.as_str(), "workforce.records"); + } + #[test] fn index_set_rejects_cross_target_capability_collisions() { let semantic = semantic_catalogue(); diff --git a/crates/graphql-orm-ai-tool-profiles/src/query_plans.rs b/crates/graphql-orm-ai-tool-profiles/src/query_plans.rs index 57bf2628..a785481d 100644 --- a/crates/graphql-orm-ai-tool-profiles/src/query_plans.rs +++ b/crates/graphql-orm-ai-tool-profiles/src/query_plans.rs @@ -142,6 +142,23 @@ impl Default for AiGraphqlQueryCapabilityLimits { } } +/// Conservative compiler-owned result-record cost bounds for one capability. +/// +/// These values describe the largest root and complete selected result the +/// closed compiler can admit. They are planning metadata only: they do not +/// reserve budget, authorize execution, or predict the number of rows an +/// authoritative resolver will actually return. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AiGraphqlResultRecordCostEstimate { + /// Maximum records admitted at the root before relationship expansion. + pub maximum_root_records: u32, + /// Maximum records admitted across the complete selected result. + pub maximum_total_records: u32, + /// Whether the provider plan must choose an explicit positive root bound. + pub root_bound_required: bool, +} + /// One closed model-authored relationship selection. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -320,6 +337,32 @@ impl AiGraphqlQueryCapability { self.operation.generated_category == Some(GeneratedGraphqlOperationCategory::Aggregate) } + /// Returns conservative result-record bounds owned by the closed compiler. + /// + /// The estimate is independent of runtime data and grants no authority. + /// Relationship selections remain subject to the total-record ceiling. + pub fn result_record_cost_estimate(&self) -> AiGraphqlResultRecordCostEstimate { + let root_bound_required = self.output.requires_root_bound(); + let maximum_root_records = match self.output { + QueryOutput::Scalar { + maximum_items: Some(maximum), + .. + } => maximum, + QueryOutput::Entity { .. } | QueryOutput::Aggregate { .. } if root_bound_required => { + self.limits + .maximum_list_items + .min(self.limits.maximum_result_records) + } + QueryOutput::Entity { .. } | QueryOutput::Scalar { .. } => 1, + QueryOutput::Aggregate { .. } => self.limits.maximum_result_records, + }; + AiGraphqlResultRecordCostEstimate { + maximum_root_records, + maximum_total_records: self.limits.maximum_result_records, + root_bound_required, + } + } + /// Compiles one typed plan into an exact immutable execution contract. /// /// # Errors @@ -1157,6 +1200,11 @@ impl AiGraphqlMutationCapability { self.execution_policy } + /// Returns conservative result-record bounds owned by the closed compiler. + pub fn result_record_cost_estimate(&self) -> AiGraphqlResultRecordCostEstimate { + self.base.result_record_cost_estimate() + } + /// Compiles one closed mutation plan into an exact server-owned operation. /// /// # Errors @@ -1512,6 +1560,11 @@ impl AiGraphqlSubscriptionCapability { self.base.semantic_operation_fingerprint() } + /// Returns conservative per-event result-record bounds owned by the closed compiler. + pub fn result_record_cost_estimate(&self) -> AiGraphqlResultRecordCostEstimate { + self.base.result_record_cost_estimate() + } + /// Compiles one bounded plan into an immutable subscription contract. /// /// # Errors diff --git a/crates/graphql-orm-ai/CHANGELOG.md b/crates/graphql-orm-ai/CHANGELOG.md index 8b4d5808..c2e23629 100644 --- a/crates/graphql-orm-ai/CHANGELOG.md +++ b/crates/graphql-orm-ai/CHANGELOG.md @@ -18,6 +18,24 @@ checkpoint facts. For the current workspace baseline and active gates, use the [implementation status](docs/implementation-status.md) and the central [AI production-readiness plan](../../docs/plans/active/ai-production-readiness/README.md). +## [0.94.0] - 2026-08-23 + +Persistent schema module: **0.63.0** (unchanged from 0.93.2). + +### Added + +- Fixed-broker `describe` results now include conservative compiler-owned root + and total result-record costs and disclose whether a root bound is required. + +### Changed + +- Capability discovery adopts tool-profile index contract version `2` and + ranks mechanical operation shape before entity, execution target, namespace, + and lexical relevance. + +There is no database, data, table, column, index, constraint, backfill, +protected-payload, GraphQL SDL, backup, or restore migration. + ## [0.93.2] - 2026-08-23 Persistent schema module: **0.63.0** (unchanged from 0.93.1). diff --git a/crates/graphql-orm-ai/Cargo.toml b/crates/graphql-orm-ai/Cargo.toml index 31904bcb..de3aead1 100644 --- a/crates/graphql-orm-ai/Cargo.toml +++ b/crates/graphql-orm-ai/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-ai" -version = "0.93.2" +version = "0.94.0" edition = "2024" authors = ["Toby Martin "] description = "Project-agnostic AI agent runtime for graphql-orm applications" diff --git a/crates/graphql-orm-ai/MIGRATION.md b/crates/graphql-orm-ai/MIGRATION.md index 5864f3fc..2464c4a4 100644 --- a/crates/graphql-orm-ai/MIGRATION.md +++ b/crates/graphql-orm-ai/MIGRATION.md @@ -19,6 +19,18 @@ they describe. For the current workspace baseline and active delivery gates, use [implementation status](docs/implementation-status.md) and the central [AI production-readiness plan](../../docs/plans/active/ai-production-readiness/README.md). +## 0.93.2 to 0.94.0: shape-aware capability discovery + +Adopt `graphql-orm-ai` 0.94.0 and `graphql-orm-ai-tool-profiles` 0.10.0 from one +reviewed full monorepo revision. Recompile capability indexes and refresh +retained provider-session bindings because index contract version `2` changes +entry, index, and index-set fingerprints. + +Search request APIs are unchanged. Fixed-broker descriptions add bounded +`resultRecordCost` planning metadata. The AI schema module remains **0.63.0**; +there is no database, data, GraphQL SDL, protected-payload, backup, or restore +migration. + ## 0.93.1 to 0.93.2: consistent current-policy browser preview gates Adopt `graphql-orm-ai` 0.93.2 from one reviewed full monorepo revision. The AI diff --git a/crates/graphql-orm-ai/README.md b/crates/graphql-orm-ai/README.md index 774d8d4b..c70fe2a7 100644 --- a/crates/graphql-orm-ai/README.md +++ b/crates/graphql-orm-ai/README.md @@ -28,7 +28,7 @@ for AI, ORM, storage, backup, and tool-profile packages: ```toml [dependencies] -graphql-orm-ai = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.93.2", default-features = false, features = ["sqlite"] } +graphql-orm-ai = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.94.0", default-features = false, features = ["sqlite"] } ``` Exactly one persistence backend is required: `sqlite` (default), `postgres`, @@ -72,7 +72,7 @@ the compiled test-backed recipe and the missing reusable bootstrap API. legacy/static read descriptors without duplicating either policy contract. Secret/`NeverExport` fields never enter provider schemas. - Deterministic per-target compact capability indexes, one canonical - collision-free multi-target index set, bounded lexical discovery, + collision-free multi-target index set, bounded shape-aware discovery, coordinator-selected eager/client-deferred/provider-deferred/fixed-broker delivery, and short-lived current-authority loaded bindings. See [capability discovery and execution](docs/capability-discovery-and-execution.md). diff --git a/crates/graphql-orm-ai/docs/capability-discovery-and-execution.md b/crates/graphql-orm-ai/docs/capability-discovery-and-execution.md index 6bd1d579..ab1024df 100644 --- a/crates/graphql-orm-ai/docs/capability-discovery-and-execution.md +++ b/crates/graphql-orm-ai/docs/capability-discovery-and-execution.md @@ -3,7 +3,7 @@ title: "Capability discovery and execution" kind: reference status: active owner: graphql-orm-ai-maintainers -last_reviewed: 2026-08-21 +last_reviewed: 2026-08-23 review_by: 2027-02-01 supersedes: [] --- @@ -70,7 +70,11 @@ AiCapabilityIndexSet::compile([Arc::new(index)]) The built-in search uses bounded normalized lexical terms, optional exact namespace/kind/entity-or-operation filters, deterministic scores and stable ID -tie-breaking. It needs no embeddings or external database. A host may later +tie-breaking. When the request declares list, details, search, keyset-page, or +aggregate intent, the matching compiler-owned operation shape is selected +before entity, target/namespace and lexical-description ranking. If the active +set has no such shape, discovery falls back to ordinary relevance rather than +inventing an operation. It needs no embeddings or external database. A host may later implement the closed current-index-set and authority traits with a reviewed search service, but returned IDs and fingerprints must still bind to the canonical set and the candidate's exact owning index. @@ -170,7 +174,10 @@ principal and passes the selected generated query through the ordinary policy, delegation, resolver and disclosure boundary. Fixed-broker `describe` returns the exact compact planning schema only on -demand. The complete description, including that schema, is bounded by +demand. It also reports conservative compiler-owned maximum root and total +result-record costs plus whether the plan must choose a positive root bound; +these values are planning metadata, not execution authority or observed row +counts. The complete description, including that schema, is bounded by `AiCapabilityDeliveryLimits::maximum_describe_bytes` (512 KiB by default and 4 MiB at the compiled ceiling). An oversized schema is not truncated or partially exposed: the response sets `planSchemaAvailable` to `false`, and the diff --git a/crates/graphql-orm-ai/docs/implementation-status.md b/crates/graphql-orm-ai/docs/implementation-status.md index b738f76a..0b53227f 100644 --- a/crates/graphql-orm-ai/docs/implementation-status.md +++ b/crates/graphql-orm-ai/docs/implementation-status.md @@ -3,16 +3,16 @@ title: "Implementation Status" kind: reference status: active owner: graphql-orm-ai-maintainers -last_reviewed: 2026-08-16 +last_reviewed: 2026-08-23 review_by: 2027-02-01 supersedes: [] --- # Implementation Status -`graphql-orm-ai` is at crate version `0.91.0` with AI schema module +`graphql-orm-ai` is at crate version `0.94.0` with AI schema module `0.63.0`. It uses workspace `graphql-orm` `0.26.0`, backend-neutral -`graphql-orm-ai-tool-profiles` `0.9.0`, and external `agql-auth` +`graphql-orm-ai-tool-profiles` `0.10.0`, and external `agql-auth` `0.18.0` at `527d15d28e3c295a6f6b5e6d74559a7aecdc1322`. Completed stateless local-provider turns can carry a proof-bearing contained diff --git a/crates/graphql-orm-ai/src/capability_delivery.rs b/crates/graphql-orm-ai/src/capability_delivery.rs index a825a540..4e46aa2b 100644 --- a/crates/graphql-orm-ai/src/capability_delivery.rs +++ b/crates/graphql-orm-ai/src/capability_delivery.rs @@ -1570,6 +1570,7 @@ fn planning_contract( "shape": entry.operation_shape, "resultClassification": entry.result_classification, "resultDescription": entry.result_description, + "resultRecordCost": entry.result_record_cost, "risk": entry.risk, "approval": entry.approval, "scalarFields": entry.scalar_fields, @@ -3073,6 +3074,63 @@ mod tests { )); } + #[tokio::test] + async fn describe_exposes_compiler_owned_result_record_cost_bounds() { + let principal = principal(); + let principal_reference = principal.reference(); + let current_index = Arc::new(CurrentIndex(RwLock::new(generated_index( + "target-policy-v1", + )))); + let broker = AiCapabilityDiscoveryBroker::new( + Arc::new(Resolver(principal)), + current_index, + Arc::new(Authority { + allowed: AtomicBool::new(true), + policy_fingerprint: RwLock::new("current-policy-v1".to_owned()), + }), + Arc::new(FixedClock::new(OffsetDateTime::UNIX_EPOCH)), + Duration::seconds(30), + ) + .expect("broker"); + let session = AiCapabilityBrokerSession::new(AiCapabilityDeliveryLimits::default()) + .expect("broker session"); + let run = run_binding(); + let discovery = broker + .dispatch_discover( + &principal_reference, + &run, + &session, + &json!({ + "text": "reviewed application record", + "kind": "generated_query", + "maximumResults": 1 + }), + ) + .await + .expect("discovery"); + let candidate = &discovery["candidates"][0]; + let description = broker + .dispatch_describe( + &principal_reference, + &run, + &session, + &json!({ + "capabilityId": candidate["capabilityId"], + "candidateFingerprint": candidate["candidateFingerprint"] + }), + ) + .await + .expect("description"); + assert_eq!( + description.contract()["resultRecordCost"], + json!({ + "maximumRootRecords": 1, + "maximumTotalRecords": 100, + "rootBoundRequired": false + }) + ); + } + #[test] fn describe_bounds_the_complete_planning_contract_without_truncation() { let description = AiCapabilityDescription { diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index 14af0dfc..6835437c 100644 --- a/docs/reference/workspace-packages.md +++ b/docs/reference/workspace-packages.md @@ -19,8 +19,8 @@ changes. | Package | Version | Path | Default features | Direct internal dependencies | | --- | --- | --- | --- | --- | | `graphql-orm` | `0.26.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | -| `graphql-orm-ai` | `0.93.2` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | -| `graphql-orm-ai-tool-profiles` | `0.9.0` | `crates/graphql-orm-ai-tool-profiles` | none | `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | +| `graphql-orm-ai` | `0.94.0` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | +| `graphql-orm-ai-tool-profiles` | `0.10.0` | `crates/graphql-orm-ai-tool-profiles` | none | `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-backup` | `0.7.1` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` | | `graphql-orm-macros` | `0.26.0` | `crates/graphql-orm-macros` | `sqlite` | none | | `graphql-orm-operation-catalog` | `0.3.0` | `crates/graphql-orm-operation-catalog` | none | `graphql-orm-router-protocol` (optional) | From 989a9b112c4762031ca4351e903338626692a34e Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Mon, 24 Aug 2026 09:50:09 +1000 Subject: [PATCH 2/2] fix(ai): preserve relevant capability discovery candidates --- .../graphql-orm-ai-tool-profiles/CHANGELOG.md | 6 +- .../graphql-orm-ai-tool-profiles/MIGRATION.md | 7 +- crates/graphql-orm-ai-tool-profiles/README.md | 10 +- .../src/capability_index.rs | 194 ++++++++++++++---- .../capability-discovery-and-execution.md | 10 +- 5 files changed, 168 insertions(+), 59 deletions(-) diff --git a/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md b/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md index f427dcdf..961be6f8 100644 --- a/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md +++ b/crates/graphql-orm-ai-tool-profiles/CHANGELOG.md @@ -19,9 +19,9 @@ supersedes: [] ### Changed -- Discovery selects an inferred mechanical list, details, search, keyset, or - aggregate shape before ranking entity, execution target, namespace, and - lexical relevance. +- Discovery ranks a narrowly inferred mechanical list, details, search, + keyset, or aggregate shape before entity, execution target, namespace, and + lexical relevance without discarding relevant mixed-shape results. - The canonical capability-index contract version is now `2`; index and set fingerprints intentionally change. diff --git a/crates/graphql-orm-ai-tool-profiles/MIGRATION.md b/crates/graphql-orm-ai-tool-profiles/MIGRATION.md index e1286094..a43bf098 100644 --- a/crates/graphql-orm-ai-tool-profiles/MIGRATION.md +++ b/crates/graphql-orm-ai-tool-profiles/MIGRATION.md @@ -18,9 +18,10 @@ refresh retained provider-session bindings: the canonical index contract is now version `2`, and the added result-record cost metadata changes entry, index, and index-set fingerprints. -Search callers keep the same request type. List/details/search/keyset/aggregate -language now selects the matching mechanical operation shape before entity, -namespace, and lexical ranking. Fixed-broker descriptions add +Search callers keep the same request type. Explicit +list/details/search/keyset/aggregate language now ranks the matching mechanical +operation shape before entity, namespace, and lexical relevance while keeping +other lexically relevant shapes eligible. Fixed-broker descriptions add `resultRecordCost` with compiler-owned `maximumRootRecords`, `maximumTotalRecords`, and `rootBoundRequired` fields. diff --git a/crates/graphql-orm-ai-tool-profiles/README.md b/crates/graphql-orm-ai-tool-profiles/README.md index 3e73ddeb..1e997a22 100644 --- a/crates/graphql-orm-ai-tool-profiles/README.md +++ b/crates/graphql-orm-ai-tool-profiles/README.md @@ -122,11 +122,11 @@ Schema, GraphQL document/SDL, database name, resolver URL, policy expression, credential, authority or secret/hidden field. `AiCapabilityIndex::search` provides bounded deterministic discovery with -exact namespace/kind/entity filters and stable ID tie-breaking. A declared -list, details, search, keyset, or aggregate intent first selects the matching -compiler-owned operation shape; public entity, execution-target, and namespace -relevance rank next, and lexical description matches break ties within that -shape. Search +exact namespace/kind/entity filters and stable ID tie-breaking. Explicit list, +details, search, keyset, or aggregate intent ranks the matching compiler-owned +operation shape first; public entity, execution-target, and namespace relevance +rank next. Every candidate still requires positive lexical relevance, and +non-matching shapes remain eligible. Search returns exact candidate/index/schema/semantic/target-policy fingerprints but grants no authority. Each entry also carries conservative compiler-owned root and total result-record bounds for later planning. The runtime package owns diff --git a/crates/graphql-orm-ai-tool-profiles/src/capability_index.rs b/crates/graphql-orm-ai-tool-profiles/src/capability_index.rs index 795ff69c..e1aeac1a 100644 --- a/crates/graphql-orm-ai-tool-profiles/src/capability_index.rs +++ b/crates/graphql-orm-ai-tool-profiles/src/capability_index.rs @@ -843,20 +843,17 @@ fn rank_entries<'a>( entries: impl IntoIterator, query: &AiCapabilitySearchQuery, ) -> Vec<(SearchRank, &'a AiCapabilityIndexEntry)> { - let terms = search_terms(&query.text); - let shape_intent = search_shape_intent(&terms); + let tokens = search_tokens(&query.text); + let terms = tokens.iter().cloned().collect::>(); + let shape_intent = search_shape_intent(&tokens); let mut ranked = entries .into_iter() .filter(|(_, entry)| entry_matches_query(entry, query)) .filter_map(|(target_id, entry)| { let rank = search_rank(target_id, entry, &terms, shape_intent); - (rank.entity > 0 || rank.target > 0 || rank.namespace > 0 || rank.lexical > 0) - .then_some((rank, entry)) + (rank.lexical > 0).then_some((rank, entry)) }) .collect::>(); - if shape_intent.is_some() && ranked.iter().any(|(rank, _)| rank.shape > 0) { - ranked.retain(|(rank, _)| rank.shape > 0); - } ranked.sort_by(|left, right| { right .0 @@ -1407,29 +1404,22 @@ fn search_rank( } } -fn search_shape_intent(terms: &BTreeSet) -> Option { - if terms.contains("count") - || terms.contains("aggregate") - || terms.contains("total") - || terms.contains("number") - || terms.contains("how") && terms.contains("many") - { +fn search_shape_intent(tokens: &[String]) -> Option { + let contains = |term: &str| tokens.iter().any(|token| token == term); + let contains_phrase = |left: &str, right: &str| { + tokens + .windows(2) + .any(|pair| pair[0] == left && pair[1] == right) + }; + if contains("count") || contains("aggregate") || contains_phrase("how", "many") { Some(AiCapabilityOperationShape::Aggregate) - } else if terms.contains("detail") || terms.contains("single") || terms.contains("specific") { + } else if contains("detail") { Some(AiCapabilityOperationShape::Details) - } else if terms.contains("search") - || terms.contains("find") - || terms.contains("match") - || terms.contains("lookup") - { + } else if contains("search") { Some(AiCapabilityOperationShape::Search) - } else if terms.contains("keyset") || terms.contains("page") || terms.contains("next") { + } else if contains("keyset") || contains("pagination") || contains("paginated") { Some(AiCapabilityOperationShape::KeysetList) - } else if terms.contains("list") - || terms.contains("all") - || terms.contains("latest") - || terms.contains("recent") - { + } else if contains("list") { Some(AiCapabilityOperationShape::List) } else { None @@ -1441,28 +1431,22 @@ fn search_shape_term(term: &str) -> bool { term, "count" | "aggregate" - | "total" - | "number" | "how" | "many" | "detail" - | "single" - | "specific" | "search" - | "find" - | "match" - | "lookup" | "keyset" - | "page" - | "next" + | "pagination" + | "paginated" | "list" - | "all" - | "latest" - | "recent" ) } fn search_terms(value: &str) -> BTreeSet { + search_tokens(value).into_iter().collect() +} + +fn search_tokens(value: &str) -> Vec { value .split(|character: char| !character.is_ascii_alphanumeric()) .filter_map(|term| { @@ -1539,23 +1523,25 @@ mod tests { .with_maximum_classification(DataClassification::Public) } - fn synthetic_shape_index() -> AiCapabilityIndex { + fn synthetic_shape_index(entity_count: usize) -> AiCapabilityIndex { let semantic = semantic_catalogue(); let target_id = GraphqlExecutionTargetId::parse("synthetic-application").expect("target"); let mut entries = BTreeMap::new(); - for entity_index in 0..101 { + for entity_index in 0..entity_count { let entity = format!("SyntheticEntity{entity_index:03}"); for shape in [ AiCapabilityOperationShape::List, AiCapabilityOperationShape::Details, AiCapabilityOperationShape::Aggregate, AiCapabilityOperationShape::Search, + AiCapabilityOperationShape::KeysetList, ] { let shape_id = match shape { AiCapabilityOperationShape::List => "list", AiCapabilityOperationShape::Details => "details", AiCapabilityOperationShape::Aggregate => "aggregate", AiCapabilityOperationShape::Search => "search", + AiCapabilityOperationShape::KeysetList => "keyset", _ => unreachable!("synthetic shape is fixed"), }; let id = format!("inventory.synthetic_{entity_index:03}_{shape_id}"); @@ -1593,9 +1579,9 @@ mod tests { } #[test] - fn four_hundred_capabilities_rank_mechanical_shape_before_lexical_ties() { - let index = synthetic_shape_index(); - assert_eq!(index.entries().len(), 404); + fn five_hundred_capabilities_rank_explicit_shape_deterministically_and_stay_bounded() { + let index = synthetic_shape_index(101); + assert_eq!(index.entries().len(), 505); for (text, expected) in [ ( "list SyntheticEntity042 records", @@ -1609,6 +1595,14 @@ mod tests { "how many SyntheticEntity042 records", AiCapabilityOperationShape::Aggregate, ), + ( + "search SyntheticEntity042 records", + AiCapabilityOperationShape::Search, + ), + ( + "pagination for SyntheticEntity042 records", + AiCapabilityOperationShape::KeysetList, + ), ] { let result = index .search(&AiCapabilitySearchQuery { @@ -1626,6 +1620,120 @@ mod tests { Some("SyntheticEntity042") ); } + + let query = AiCapabilitySearchQuery { + text: "list records".to_owned(), + namespace: None, + kind: Some(AiCapabilityKind::GeneratedQuery), + entity_or_operation: None, + maximum_results: 7, + }; + let first = index.search(&query).expect("first bounded search"); + let second = index.search(&query).expect("second bounded search"); + assert_eq!(first, second); + assert_eq!(first.candidates.len(), 7); + assert!( + first + .candidates + .iter() + .all(|candidate| candidate.operation_shape == AiCapabilityOperationShape::List) + ); + assert_eq!( + first + .candidates + .iter() + .map(|candidate| candidate.id.as_str()) + .collect::>(), + (0..7) + .map(|index| format!("inventory.synthetic_{index:03}_list")) + .collect::>() + ); + } + + #[test] + fn shape_rank_preserves_lexically_relevant_mixed_shape_candidates() { + let index = synthetic_shape_index(1); + let result = index + .search(&AiCapabilitySearchQuery { + text: "list SyntheticEntity000 records".to_owned(), + namespace: None, + kind: Some(AiCapabilityKind::GeneratedQuery), + entity_or_operation: None, + maximum_results: 5, + }) + .expect("mixed-shape search"); + assert_eq!(result.candidates.len(), 5); + assert_eq!( + result.candidates[0].operation_shape, + AiCapabilityOperationShape::List + ); + assert_eq!( + result + .candidates + .iter() + .map(|candidate| candidate.operation_shape) + .collect::>(), + BTreeSet::from([ + AiCapabilityOperationShape::List, + AiCapabilityOperationShape::Details, + AiCapabilityOperationShape::Aggregate, + AiCapabilityOperationShape::Search, + AiCapabilityOperationShape::KeysetList, + ]) + ); + } + + #[test] + fn ordinary_request_vocabulary_does_not_infer_operation_shape() { + for text in [ + "total value", + "number assigned", + "single owner", + "specific policy", + "find matching records", + "lookup code", + "next action", + "page content", + "all current records", + "latest revision", + "recent incident", + "many records explain how they changed", + ] { + assert_eq!( + search_shape_intent(&search_tokens(text)), + None, + "unexpected shape intent for {text:?}" + ); + } + for (text, expected) in [ + ("count records", AiCapabilityOperationShape::Aggregate), + ("how many records", AiCapabilityOperationShape::Aggregate), + ("record details", AiCapabilityOperationShape::Details), + ("search records", AiCapabilityOperationShape::Search), + ("paginated records", AiCapabilityOperationShape::KeysetList), + ("list records", AiCapabilityOperationShape::List), + ] { + assert_eq!( + search_shape_intent(&search_tokens(text)), + Some(expected), + "missing explicit shape intent for {text:?}" + ); + } + } + + #[test] + fn unrelated_query_cannot_admit_zero_lexical_candidates_at_high_cardinality() { + let index = synthetic_shape_index(101); + let result = index + .search(&AiCapabilitySearchQuery { + text: "list unrelated phrase".to_owned(), + namespace: None, + kind: Some(AiCapabilityKind::GeneratedQuery), + entity_or_operation: None, + maximum_results: 32, + }) + .expect("unrelated search"); + assert!(result.candidates.is_empty()); } #[test] diff --git a/crates/graphql-orm-ai/docs/capability-discovery-and-execution.md b/crates/graphql-orm-ai/docs/capability-discovery-and-execution.md index ab1024df..ffcc572d 100644 --- a/crates/graphql-orm-ai/docs/capability-discovery-and-execution.md +++ b/crates/graphql-orm-ai/docs/capability-discovery-and-execution.md @@ -70,11 +70,11 @@ AiCapabilityIndexSet::compile([Arc::new(index)]) The built-in search uses bounded normalized lexical terms, optional exact namespace/kind/entity-or-operation filters, deterministic scores and stable ID -tie-breaking. When the request declares list, details, search, keyset-page, or -aggregate intent, the matching compiler-owned operation shape is selected -before entity, target/namespace and lexical-description ranking. If the active -set has no such shape, discovery falls back to ordinary relevance rather than -inventing an operation. It needs no embeddings or external database. A host may later +tie-breaking. Narrow, explicit list, details, search, keyset-page, or aggregate +intent ranks the matching compiler-owned operation shape before entity, +target/namespace and lexical-description relevance. Every result must retain a +positive lexical match, and relevant non-matching shapes are not discarded. It +needs no embeddings or external database. A host may later implement the closed current-index-set and authority traits with a reviewed search service, but returned IDs and fingerprints must still bind to the canonical set and the candidate's exact owning index.