|
| 1 | +//! `SchemaField` Node emission + `MirrorsField` heuristic edge bucketing |
| 2 | +//! (T4-7). |
| 3 | +//! |
| 4 | +//! Bridges the gap between per-file `RawSchemaField` (T4-1..T4-6 detectors) |
| 5 | +//! and the queryable graph layer: each `RawSchemaField` becomes a |
| 6 | +//! `NodeKind::SchemaField` Node connected to its owning class via |
| 7 | +//! `HasProperty`, and cross-framework mirrors (e.g. Pydantic `User.email` |
| 8 | +//! vs SQLAlchemy `User.email`) are linked by the heuristic `MirrorsField` |
| 9 | +//! edge. |
| 10 | +//! |
| 11 | +//! ## Algorithm |
| 12 | +//! |
| 13 | +//! 1. **Promote**: iterate `LocalGraph.schema_fields`, emit one |
| 14 | +//! `SchemaField` Node + one `HasProperty` edge (Class → SchemaField) |
| 15 | +//! per field. SymbolTable lookup resolves the owning class name in |
| 16 | +//! the same file; misses silently drop the field (no false-positive |
| 17 | +//! edge to a wrong class). |
| 18 | +//! |
| 19 | +//! 2. **Bucket**: group emitted SchemaField Nodes by |
| 20 | +//! `(name.to_lowercase(), SchemaType)` so case-variants and |
| 21 | +//! type-compatible mirrors share a bucket. |
| 22 | +//! |
| 23 | +//! 3. **Pair within bucket**: apply the 4-point strict rubric per pair — |
| 24 | +//! exact case-sensitive name, identical `SchemaType` (granted by |
| 25 | +//! bucket key), identical owner-class name, and bidirectional top-1. |
| 26 | +//! For k=2 the top-1 check is trivial; for k≥3 with a uniform |
| 27 | +//! `(name, type, owner_class)` triple, D3 cluster semantics emit all |
| 28 | +//! `k×(k−1)/2` pairs at confidence 0.9. Different owner-class within |
| 29 | +//! the same bucket is currently dropped (BlindSpot emission is a |
| 30 | +//! documented follow-up — see `test_partial_match_emits_blindspot`). |
| 31 | +//! |
| 32 | +//! ## Perf |
| 33 | +//! |
| 34 | +//! - O(N) bucket build; O(k²) pairwise emission where k = #fields sharing |
| 35 | +//! `(name_lc, type)`. Typical k < 10 (real corpora rarely have more |
| 36 | +//! than a handful of cross-framework mirrors per identifier). |
| 37 | +//! - Offline-only — runs once at build time, never on `ecp` hot paths. |
| 38 | +
|
| 39 | +use crate::resolution::index::SymbolTable; |
| 40 | +use ecp_core::analyzer::types::{LocalGraph, SchemaType}; |
| 41 | +use ecp_core::graph::{Edge, Node, NodeKind, RelType}; |
| 42 | +use ecp_core::pool::StringPool; |
| 43 | +use ecp_core::uid; |
| 44 | +use rustc_hash::FxHashMap; |
| 45 | + |
| 46 | +/// Promote `RawSchemaField`s to `SchemaField` Nodes + emit `HasProperty` |
| 47 | +/// connections + `MirrorsField` heuristic edges. Returns the count of |
| 48 | +/// emitted MirrorsField edges (HasProperty + SchemaField Node counts are |
| 49 | +/// derivable from `nodes.len()` delta; this return is for telemetry). |
| 50 | +/// |
| 51 | +/// `file_node_count_before` is the size of `nodes` BEFORE the File-node |
| 52 | +/// append loop runs — used to bound SchemaField nodes to the |
| 53 | +/// raw-symbols-and-extras region so `file_node_idx` registration further |
| 54 | +/// downstream still hits the correct File-node range. |
| 55 | +pub fn emit_edges( |
| 56 | + local_graphs: &[LocalGraph], |
| 57 | + symbol_table: &SymbolTable, |
| 58 | + string_pool: &mut StringPool, |
| 59 | + nodes: &mut Vec<Node>, |
| 60 | + edges: &mut Vec<Edge>, |
| 61 | +) -> usize { |
| 62 | + let reason_has_property = string_pool.add("post_process:schema_field:has_property"); |
| 63 | + let reason_mirror = string_pool.add("post_process:schema_field:mirrors_field"); |
| 64 | + |
| 65 | + /// Bucket entry: (node_idx, owner_class, exact_name). |
| 66 | + type BucketEntry<'a> = (u32, &'a str, &'a str); |
| 67 | + let mut buckets: FxHashMap<(String, SchemaType), Vec<BucketEntry<'_>>> = FxHashMap::default(); |
| 68 | + |
| 69 | + // Phase 1 — emit SchemaField Nodes + HasProperty edges, populate buckets. |
| 70 | + // |
| 71 | + // Skip `LocalGraph`s with no `schema_fields` (the majority — most files |
| 72 | + // carry no ORM/schema surface). Per-file Owner-class lookup uses |
| 73 | + // SymbolTable's per-file index; misses (e.g. extractor emitted a class |
| 74 | + // name that SymbolTable doesn't know about, perhaps due to file |
| 75 | + // boundary edge cases) silently drop the field. No fabricated edges. |
| 76 | + for (lg_idx, local_graph) in local_graphs.iter().enumerate() { |
| 77 | + let Some(ref schema_fields) = local_graph.schema_fields else { |
| 78 | + continue; |
| 79 | + }; |
| 80 | + if schema_fields.is_empty() { |
| 81 | + continue; |
| 82 | + } |
| 83 | + let path_str = local_graph.file_path.to_string_lossy().replace('\\', "/"); |
| 84 | + let file_idx = lg_idx as u32; |
| 85 | + |
| 86 | + for raw_sf in schema_fields.iter() { |
| 87 | + let owner_name = &*raw_sf.owner_class; |
| 88 | + let field_name = &*raw_sf.name; |
| 89 | + |
| 90 | + // Resolve the owning class to an existing Node idx. If the |
| 91 | + // SymbolTable doesn't know `owner_name` in this file, the |
| 92 | + // class was not parsed as a `Class` / `Struct` / `Trait` / |
| 93 | + // `Interface` — likely a generated-code or DSL pattern that |
| 94 | + // we don't model. Silently skip rather than emit dangling |
| 95 | + // HasProperty. |
| 96 | + let Some(class_idx) = symbol_table.lookup_in_file(&path_str, owner_name) else { |
| 97 | + continue; |
| 98 | + }; |
| 99 | + |
| 100 | + // UID: canonical xxh3-64 over (kind, path, owner_class, name). |
| 101 | + // owner_class is part of the hash so cross-framework mirrors with |
| 102 | + // same (owner, name) but different frameworks get distinct UIDs |
| 103 | + // via the field_name component (`framework.field` would also work |
| 104 | + // but the canonical helper expects the pure symbol name). |
| 105 | + let sf_uid = uid::compute( |
| 106 | + NodeKind::SchemaField, |
| 107 | + &path_str, |
| 108 | + Some(owner_name), |
| 109 | + field_name, |
| 110 | + ); |
| 111 | + let name_ref = string_pool.add(field_name); |
| 112 | + let owner_ref = string_pool.add(owner_name); |
| 113 | + let sf_idx = nodes.len() as u32; |
| 114 | + nodes.push(Node { |
| 115 | + uid: sf_uid, |
| 116 | + name: name_ref, |
| 117 | + file_idx, |
| 118 | + kind: NodeKind::SchemaField, |
| 119 | + span: raw_sf.span, |
| 120 | + community_id: 0, |
| 121 | + owner_class: owner_ref, |
| 122 | + content_hash: 0, |
| 123 | + }); |
| 124 | + |
| 125 | + // HasProperty: <Class> -> <SchemaField>. Non-heuristic |
| 126 | + // (extractor saw an actual `name: T` / `Column(T)` form, so |
| 127 | + // the class-owns-this-field claim is structural, not inferred). |
| 128 | + edges.push(Edge { |
| 129 | + source: class_idx, |
| 130 | + target: sf_idx, |
| 131 | + rel_type: RelType::HasProperty, |
| 132 | + confidence: 1.0, |
| 133 | + reason: reason_has_property, |
| 134 | + }); |
| 135 | + |
| 136 | + // Bucket key: (lowercase_name, type). Lowercase normalizes |
| 137 | + // `email` vs `Email`; type-class match keeps `email: str` |
| 138 | + // from binding to `email: int`. |
| 139 | + buckets |
| 140 | + .entry((field_name.to_ascii_lowercase(), raw_sf.type_class)) |
| 141 | + .or_default() |
| 142 | + .push((sf_idx, owner_name, field_name)); |
| 143 | + } |
| 144 | + } |
| 145 | + |
| 146 | + // Phase 2 — pairwise MirrorsField emission within each bucket. |
| 147 | + // |
| 148 | + // Rubric (per spec line 540 + D3 cluster semantics): |
| 149 | + // - exact case-sensitive name match |
| 150 | + // - identical SchemaType (granted by bucket key) |
| 151 | + // - identical owner-class name (e.g. both "User") |
| 152 | + // - bidirectional top-1 (trivial for k=2; D3 covers k≥3 uniform) |
| 153 | + // |
| 154 | + // Implementation: sub-group bucket by (exact_name, owner_class). Any |
| 155 | + // sub-group of size ≥ 2 satisfies all four points and emits pairwise |
| 156 | + // `MirrorsField` at heuristic confidence (RelType::MirrorsField is |
| 157 | + // listed under `is_heuristic` so default `ecp impact` hides these |
| 158 | + // unless `--show-heuristic` is set). |
| 159 | + // |
| 160 | + // BlindSpot emission for partial matches (3/4) is a documented |
| 161 | + // follow-up — see ignored test `test_partial_match_emits_blindspot`. |
| 162 | + let mut mirror_count = 0usize; |
| 163 | + for entries in buckets.values() { |
| 164 | + if entries.len() < 2 { |
| 165 | + continue; |
| 166 | + } |
| 167 | + // Sub-group by (exact_name, owner_class). |
| 168 | + let mut sub: FxHashMap<(&str, &str), Vec<u32>> = FxHashMap::default(); |
| 169 | + for &(idx, owner, name) in entries { |
| 170 | + sub.entry((name, owner)).or_default().push(idx); |
| 171 | + } |
| 172 | + for group in sub.values() { |
| 173 | + if group.len() < 2 { |
| 174 | + continue; |
| 175 | + } |
| 176 | + // Pairwise emit. Deterministic order: idx-ascending pairs |
| 177 | + // (i, j) where i < j by position in `group`. The vec is |
| 178 | + // populated in iteration order from `entries`, which itself |
| 179 | + // mirrors LocalGraph iteration order — stable across runs. |
| 180 | + for i in 0..group.len() { |
| 181 | + for j in (i + 1)..group.len() { |
| 182 | + edges.push(Edge { |
| 183 | + source: group[i], |
| 184 | + target: group[j], |
| 185 | + rel_type: RelType::MirrorsField, |
| 186 | + confidence: 0.9, |
| 187 | + reason: reason_mirror, |
| 188 | + }); |
| 189 | + mirror_count += 1; |
| 190 | + } |
| 191 | + } |
| 192 | + } |
| 193 | + } |
| 194 | + |
| 195 | + mirror_count |
| 196 | +} |
0 commit comments