Skip to main content

codehelion_core/semantic/
candidates.rs

1use super::{
2    BTreeMap, BTreeSet, CloneClass, Confidence, GroupingConfig, GroupingUnit, OperationKind,
3    RuleMatch, SOG_SCHEMA_VERSION, SemanticOperationGraph, SemanticRule, SimilarityEdge, grouping,
4    match_registered_rule, registered_rules,
5};
6
7/// Limits for the registered SOG candidate index.
8///
9/// Both limits cut whole index buckets. Cutting part of a bucket would make
10/// the answer depend on incidental graph order, and could leave a reported
11/// group with unexamined peers that look equally eligible.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub struct SemanticCandidateConfig {
14    /// Largest operation-sequence bucket that may enter verification.
15    pub max_bucket_members: usize,
16    /// Largest number of candidate pairs the extraction may return.
17    pub max_candidate_pairs: usize,
18}
19
20impl Default for SemanticCandidateConfig {
21    fn default() -> Self {
22        Self {
23            max_bucket_members: 256,
24            max_candidate_pairs: 16_384,
25        }
26    }
27}
28
29/// Accounting for registered SOG candidate extraction.
30#[derive(Debug, Clone, Default, PartialEq, Eq)]
31pub struct SemanticCandidateStats {
32    /// Graphs presented to the extractor.
33    pub graphs: usize,
34    /// Graphs outside the current schema or too short for a registered rule.
35    pub ineligible_graphs: usize,
36    /// Distinct BuildVariant-and-operation-sequence buckets formed.
37    pub buckets: usize,
38    /// Buckets omitted in full for exceeding [`SemanticCandidateConfig::max_bucket_members`].
39    pub oversized_buckets: usize,
40    /// Pairs in eligible buckets before the run-wide ceiling is applied.
41    pub pairs_available: usize,
42    /// Pairs omitted in full because accepting their bucket would exceed the ceiling.
43    pub pairs_budget_dropped: usize,
44    /// Candidate pairs returned to a registered rule verifier.
45    pub pairs_emitted: usize,
46}
47
48/// One pair selected by the bounded SOG candidate index.
49///
50/// The positions index the caller's graph slice. They are not source anchors
51/// and never become stable finding identifiers.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
53pub struct SemanticCandidatePair {
54    /// Position of the first graph in caller order.
55    pub left: usize,
56    /// Position of the second graph in caller order.
57    pub right: usize,
58}
59
60/// Position-free identity of one SOG-owning unit supplied to semantic
61/// grouping.
62///
63/// The index in the input slice identifies the unit only for this invocation.
64/// `key` is its normalized semantic fragment fingerprint, used solely for
65/// deterministic medoid selection and output ordering.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct SemanticGroupingUnit {
68    /// Stable normalized semantic fragment identity.
69    pub key: [u8; 16],
70}
71
72/// One verified semantic candidate paired with the rule that justified it.
73#[derive(Debug, Clone, Copy, PartialEq)]
74pub struct VerifiedSemanticPair {
75    /// Endpoints into the `SemanticGroupingUnit` input slice.
76    pub candidate: SemanticCandidatePair,
77    /// The closed registered rule that accepted the endpoints.
78    pub matched: RuleMatch,
79}
80
81/// A cohesive set of SOG-owning units justified by one registered rule.
82///
83/// Every pair of members was separately accepted by `rule`. In particular,
84/// this is not a connected component of pair matches: an absent pair is
85/// treated as incompatible by complete-linkage refinement.
86#[derive(Debug, Clone, PartialEq)]
87pub struct SemanticRuleGroup {
88    /// The sole registered rule that explains every internal relation.
89    pub rule: SemanticRule,
90    /// The deterministic medoid, indexed into the caller's unit slice.
91    pub canonical: usize,
92    /// Member unit indices, with the canonical unit first.
93    pub members: Vec<usize>,
94    /// Weakest accepted internal relation. This is always `1.0` for the
95    /// binary registered-rule relation, but is retained as explicit evidence
96    /// of the complete-linkage contract.
97    pub min_pairwise: f64,
98}
99
100/// Semantic pairs left outside a cohesive group, with an explicit reason.
101#[derive(Debug, Clone, Copy, PartialEq)]
102pub struct UngroupedSemanticPair {
103    /// The verified pair that no emitted group jointly represents.
104    pub pair: VerifiedSemanticPair,
105    /// Whether the grouping ceiling prevented this pair from being considered
106    /// alongside the other endpoint, rather than complete-linkage rejecting a
107    /// non-transitive chain.
108    pub severed_by_the_ceiling: bool,
109}
110
111/// Accounting for registered semantic grouping.
112#[derive(Debug, Clone, Default, PartialEq, Eq)]
113pub struct SemanticGroupingStats {
114    /// Input pairs whose endpoints were in range and non-identical.
115    pub verified_pairs: usize,
116    /// Duplicate copies of one rule-and-endpoint relation ignored
117    /// deterministically.
118    pub duplicate_pairs: usize,
119    /// Input pairs rejected because an endpoint was outside the unit slice or
120    /// both endpoints named the same unit.
121    pub invalid_pairs: usize,
122    /// Pairs expressed by an emitted cohesive group.
123    pub grouped_pairs: usize,
124    /// Verified pairs that no emitted group jointly represents.
125    pub ungrouped_pairs: usize,
126    /// Ungrouped pairs separated only by the grouping ceiling.
127    pub ceiling_severed_pairs: usize,
128    /// Cohesive rule groups emitted.
129    pub groups: usize,
130}
131
132/// Cohesive semantic groups and the verified pairs they do not represent.
133#[derive(Debug, Clone, PartialEq)]
134pub struct SemanticGrouping {
135    /// Groups partitioned by registered rule and refined with complete linkage.
136    pub groups: Vec<SemanticRuleGroup>,
137    /// Verified pairs retained separately when no group holds both endpoints.
138    pub ungrouped: Vec<UngroupedSemanticPair>,
139    /// Full grouping accounting, including bounded-refinement effects.
140    pub stats: SemanticGroupingStats,
141}
142
143/// Candidate pairs and their complete accounting.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct SemanticCandidateExtraction {
146    /// Pairs narrowed by the coarse index, in deterministic order.
147    pub pairs: Vec<SemanticCandidatePair>,
148    /// What the extractor considered and deliberately omitted.
149    pub stats: SemanticCandidateStats,
150}
151
152/// Extract bounded candidate pairs for registered SOG rules.
153///
154/// The inverted index partitions first by the complete `BuildVariant`
155/// fingerprint and then by the operation-kind sequence. It therefore never
156/// reconnects independent build variants and avoids a project-wide all-pairs
157/// comparison. API names and type categories remain evidence for the rule
158/// verifier rather than becoming a lossy cross-language index key.
159#[must_use]
160pub fn extract_registered_candidates(
161    graphs: &[SemanticOperationGraph],
162    config: SemanticCandidateConfig,
163) -> SemanticCandidateExtraction {
164    #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
165    struct CandidateKey {
166        variant: [u8; 32],
167        language: &'static str,
168        operations: Vec<OperationKind>,
169    }
170
171    let mut stats = SemanticCandidateStats {
172        graphs: graphs.len(),
173        ..SemanticCandidateStats::default()
174    };
175    let mut index: BTreeMap<CandidateKey, Vec<usize>> = BTreeMap::new();
176    for (index_in_input, graph) in graphs.iter().enumerate() {
177        if graph.schema_version != SOG_SCHEMA_VERSION
178            || !registered_rules()
179                .iter()
180                .any(|rule| rule.pattern.accepts(graph))
181        {
182            stats.ineligible_graphs += 1;
183            continue;
184        }
185        index
186            .entry(CandidateKey {
187                variant: graph.build_variant_fingerprint,
188                language: graph.language.name(),
189                operations: graph.nodes.iter().map(|node| node.kind).collect(),
190            })
191            .or_default()
192            .push(index_in_input);
193    }
194    stats.buckets = index.len();
195
196    let mut pairs = Vec::new();
197    for members in index.into_values() {
198        if members.len() > config.max_bucket_members {
199            stats.oversized_buckets += 1;
200            continue;
201        }
202        let available = members
203            .len()
204            .saturating_mul(members.len().saturating_sub(1))
205            / 2;
206        stats.pairs_available = stats.pairs_available.saturating_add(available);
207        if pairs.len().saturating_add(available) > config.max_candidate_pairs {
208            stats.pairs_budget_dropped = stats.pairs_budget_dropped.saturating_add(available);
209            continue;
210        }
211        for (offset, &left) in members.iter().enumerate() {
212            pairs.extend(
213                members[offset + 1..]
214                    .iter()
215                    .copied()
216                    .map(|right| SemanticCandidatePair { left, right }),
217            );
218        }
219    }
220    stats.pairs_emitted = pairs.len();
221    SemanticCandidateExtraction { pairs, stats }
222}
223
224/// Verify candidate pairs against the registered rules.
225///
226/// A pair outside the provided slice is ignored rather than guessed at. The
227/// extractor only produces in-range pairs, but this makes callers that load
228/// persisted candidate data fail closed as well.
229#[must_use]
230pub fn verify_registered_candidates(
231    graphs: &[SemanticOperationGraph],
232    candidates: &[SemanticCandidatePair],
233) -> Vec<(SemanticCandidatePair, RuleMatch)> {
234    candidates
235        .iter()
236        .filter_map(|&candidate| {
237            let (Some(left), Some(right)) =
238                (graphs.get(candidate.left), graphs.get(candidate.right))
239            else {
240                return None;
241            };
242            match_registered_rule(left, right).map(|rule_match| (candidate, rule_match))
243        })
244        .collect()
245}
246
247/// Group verified registered-rule pairs without treating pair compatibility as
248/// transitive.
249///
250/// Rules are grouped independently, so a unit cannot connect two different
251/// semantic claims merely because it participates in both. Within a rule, a
252/// verified pair is a binary relation with similarity `1.0`; any pair the
253/// verifier did not accept is absent and therefore reads as incompatible to
254/// complete-linkage refinement. This turns a partially connected match graph
255/// into cohesive groups while retaining every accepted relation no group can
256/// express as an [`UngroupedSemanticPair`].
257///
258/// Invalid and duplicate inputs are ignored with explicit accounting. The
259/// normal verifier cannot create either, but this keeps persisted or adapter
260/// supplied pair data fail-closed.
261#[must_use]
262#[allow(
263    clippy::too_many_lines,
264    reason = "the adapter keeps validation, per-rule partitioning, complete-linkage refinement, and every ungrouped-pair reason in one auditable boundary"
265)]
266pub fn group_verified_semantic_pairs(
267    units: &[SemanticGroupingUnit],
268    verified: &[VerifiedSemanticPair],
269    config: &GroupingConfig,
270) -> SemanticGrouping {
271    let mut stats = SemanticGroupingStats::default();
272    let mut partitions: BTreeMap<(&str, u32), SemanticRulePartition> = BTreeMap::new();
273    for &pair in verified {
274        let candidate = ordered_semantic_pair(pair.candidate);
275        if candidate.left == candidate.right
276            || candidate.left >= units.len()
277            || candidate.right >= units.len()
278        {
279            stats.invalid_pairs = stats.invalid_pairs.saturating_add(1);
280            continue;
281        }
282        let key = (pair.matched.rule.id, pair.matched.rule.version);
283        let partition = partitions
284            .entry(key)
285            .or_insert_with(|| SemanticRulePartition::new(pair.matched.rule));
286        if partition
287            .pairs
288            .insert(
289                (candidate.left, candidate.right),
290                VerifiedSemanticPair {
291                    candidate,
292                    matched: pair.matched,
293                },
294            )
295            .is_some()
296        {
297            stats.duplicate_pairs = stats.duplicate_pairs.saturating_add(1);
298        }
299    }
300
301    let mut groups = Vec::new();
302    let mut ungrouped = Vec::new();
303    for partition in partitions.into_values() {
304        stats.verified_pairs = stats.verified_pairs.saturating_add(partition.pairs.len());
305        let mut global_members = BTreeSet::new();
306        for pair in partition.pairs.values() {
307            global_members.insert(pair.candidate.left);
308            global_members.insert(pair.candidate.right);
309        }
310        let global_members: Vec<_> = global_members.into_iter().collect();
311        let local_positions: BTreeMap<_, _> = global_members
312            .iter()
313            .copied()
314            .enumerate()
315            .map(|(local, global)| (global, local))
316            .collect();
317        let grouping_units: Vec<_> = global_members
318            .iter()
319            .map(|&global| GroupingUnit {
320                key: units[global].key,
321            })
322            .collect();
323        let edges: Vec<_> = partition
324            .pairs
325            .values()
326            .map(|pair| SimilarityEdge {
327                a: local_positions[&pair.candidate.left],
328                b: local_positions[&pair.candidate.right],
329                similarity: 1.0,
330                breakdown: None,
331                class: CloneClass::RestrictedSemantic,
332                confidence: Confidence::High,
333            })
334            .collect();
335        let grouped = grouping::group(&grouping_units, &edges, config);
336        let mut represented = BTreeSet::new();
337        for group in &grouped.groups {
338            let members: Vec<_> = group
339                .members
340                .iter()
341                .map(|&local| global_members[local])
342                .collect();
343            for (offset, &left) in members.iter().enumerate() {
344                for &right in &members[offset + 1..] {
345                    represented.insert(ordered_usize_pair(left, right));
346                }
347            }
348            groups.push(SemanticRuleGroup {
349                rule: partition.rule,
350                canonical: global_members[group.canonical],
351                members,
352                min_pairwise: group.min_pairwise,
353            });
354        }
355        for pair in partition.pairs.into_values() {
356            let endpoints = (pair.candidate.left, pair.candidate.right);
357            if represented.contains(&endpoints) {
358                stats.grouped_pairs = stats.grouped_pairs.saturating_add(1);
359                continue;
360            }
361            let severed_by_the_ceiling = grouped.severed_by_the_ceiling(
362                local_positions[&pair.candidate.left],
363                local_positions[&pair.candidate.right],
364            );
365            if severed_by_the_ceiling {
366                stats.ceiling_severed_pairs = stats.ceiling_severed_pairs.saturating_add(1);
367            }
368            ungrouped.push(UngroupedSemanticPair {
369                pair,
370                severed_by_the_ceiling,
371            });
372        }
373    }
374    stats.ungrouped_pairs = ungrouped.len();
375    groups.sort_by(|left, right| {
376        left.rule
377            .id
378            .cmp(right.rule.id)
379            .then(left.rule.version.cmp(&right.rule.version))
380            .then(units[left.canonical].key.cmp(&units[right.canonical].key))
381            .then(left.members.len().cmp(&right.members.len()))
382    });
383    ungrouped.sort_by(|left, right| {
384        left.pair
385            .matched
386            .rule
387            .id
388            .cmp(right.pair.matched.rule.id)
389            .then(
390                left.pair
391                    .matched
392                    .rule
393                    .version
394                    .cmp(&right.pair.matched.rule.version),
395            )
396            .then(left.pair.candidate.cmp(&right.pair.candidate))
397    });
398    stats.groups = groups.len();
399    SemanticGrouping {
400        groups,
401        ungrouped,
402        stats,
403    }
404}
405
406/// Partition of verified pairs justified by exactly one registered rule.
407struct SemanticRulePartition {
408    rule: SemanticRule,
409    pairs: BTreeMap<(usize, usize), VerifiedSemanticPair>,
410}
411
412impl SemanticRulePartition {
413    const fn new(rule: SemanticRule) -> Self {
414        Self {
415            rule,
416            pairs: BTreeMap::new(),
417        }
418    }
419}
420
421/// Normalize a semantic pair so duplicate relations have one representation.
422const fn ordered_semantic_pair(pair: SemanticCandidatePair) -> SemanticCandidatePair {
423    let (left, right) = ordered_usize_pair(pair.left, pair.right);
424    SemanticCandidatePair { left, right }
425}
426
427/// Normalize one undirected endpoint pair.
428const fn ordered_usize_pair(left: usize, right: usize) -> (usize, usize) {
429    if left <= right {
430        (left, right)
431    } else {
432        (right, left)
433    }
434}