Skip to main content

nusy_codegraph/
schema.rs

1//! Arrow schemas for code graph objects.
2//!
3//! Two tables:
4//! - **CodeNodes**: functions, classes, modules, files — the objects in the code graph
5//! - **CodeEdges**: calls, imports, inheritance, containment — relationships between objects
6//!
7//! ## V12a-1 additions (EX-3168)
8//!
9//! **V12/V13 parity audit:** V12/V13 Python had no position metadata and no Rust-specific
10//! node kinds. All position columns and Rust-specific `CodeNodeKind` variants are new work,
11//! not ports from V12.
12//!
13//! Position columns (13–18) are nullable — backward-compatible with existing Parquet files.
14//! New Parquet files produced by the Rust tree-sitter parser (EX-3120) will populate them.
15
16use arrow::datatypes::{DataType, Field, Schema, TimeUnit};
17use std::sync::Arc;
18
19/// Default embedding dimension for code objects.
20pub const CODE_EMBEDDING_DIM: i32 = 768;
21
22// ─── CodeNode column indices ────────────────────────────────────────────────
23
24/// Named column indices for the CodeNodes schema.
25pub mod node_col {
26    pub const ID: usize = 0;
27    pub const KIND: usize = 1;
28    pub const PARENT_ID: usize = 2;
29    pub const NAME: usize = 3;
30    pub const SIGNATURE: usize = 4;
31    pub const DOCSTRING: usize = 5;
32    pub const BODY_HASH: usize = 6;
33    pub const BODY: usize = 7;
34    pub const EMBEDDING: usize = 8;
35    pub const LOC: usize = 9;
36    pub const CYCLOMATIC_COMPLEXITY: usize = 10;
37    pub const COVERAGE_PCT: usize = 11;
38    pub const LAST_MODIFIED: usize = 12;
39    // Position metadata (EX-3168 / V12a-1) — nullable, populated by tree-sitter parser
40    pub const START_LINE: usize = 13;
41    pub const END_LINE: usize = 14;
42    pub const START_COL: usize = 15;
43    pub const END_COL: usize = 16;
44    pub const FILE_PATH: usize = 17;
45    pub const BYTE_OFFSET: usize = 18;
46}
47
48// ─── CodeEdge column indices ────────────────────────────────────────────────
49
50/// Named column indices for the CodeEdges schema.
51pub mod edge_col {
52    pub const SOURCE_ID: usize = 0;
53    pub const TARGET_ID: usize = 1;
54    pub const PREDICATE: usize = 2;
55    pub const WEIGHT: usize = 3;
56    pub const COMMIT_ID: usize = 4;
57}
58
59// ─── Enums ──────────────────────────────────────────────────────────────────
60
61/// Kind of code object.
62///
63/// Variants are stored as Arrow `Dictionary<Int8, Utf8>` for efficient memory use.
64///
65/// ## Variant groups
66///
67/// **Language-agnostic** (original set — used by the Python parser):
68/// `File`, `Module`, `Class`, `Function`, `Method`, `Parameter`, `Variable`, `Test`
69///
70/// **Generic** (EX-3168 / V12a-1 — language-independent additions):
71/// `Type`, `Constant`, `Import`
72///
73/// **Rust-specific** (EX-3168 / V12a-1 — populated by tree-sitter parser in EX-3120):
74/// `RustFn`, `RustMethod`, `RustImpl`, `RustTrait`, `RustStruct`, `RustEnum`,
75/// `RustMod`, `RustMacro`, `RustUse`, `RustConst`, `RustStatic`, `RustTypeAlias`,
76/// `RustAttribute`, `RustLifetime`, `RustTest`
77///
78/// **Python-specific** (EX-3172 / V12b-1 — populated by PythonParser with position metadata):
79/// `PythonFunction`, `PythonMethod`, `PythonClass`, `PythonDecorator`, `PythonImport`,
80/// `PythonModule`, `PythonLambda`, `PythonAsync`, `PythonProperty`
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
82pub enum CodeNodeKind {
83    // ── Language-agnostic (original set, used by Python parser) ───────────
84    File,
85    Module,
86    Class,
87    Function,
88    Method,
89    Parameter,
90    Variable,
91    Test,
92    // ── Generic additions (EX-3168) ────────────────────────────────────────
93    /// Named type (struct, class, enum — language-agnostic abstraction)
94    Type,
95    /// Compile-time or declaration-time constant value
96    Constant,
97    /// Module import / use declaration
98    Import,
99    // ── Rust-specific (EX-3168, populated by EX-3120 tree-sitter parser) ──
100    /// `fn foo()` — free function
101    RustFn,
102    /// `fn method(&self)` inside an impl block
103    RustMethod,
104    /// `impl Foo { ... }` or `impl Trait for Foo { ... }`
105    RustImpl,
106    /// `trait Foo { ... }`
107    RustTrait,
108    /// `struct Foo { ... }`
109    RustStruct,
110    /// `enum Foo { ... }`
111    RustEnum,
112    /// `mod foo { ... }` or `mod foo;`
113    RustMod,
114    /// `macro_rules! name { ... }`
115    RustMacro,
116    /// `use path::to::Item;`
117    RustUse,
118    /// `const NAME: Type = value;`
119    RustConst,
120    /// `static NAME: Type = value;`
121    RustStatic,
122    /// `type Foo = Bar;`
123    RustTypeAlias,
124    /// `#[derive(...)]` or other attributes (stored separately from node)
125    RustAttribute,
126    /// Lifetime parameter `'a` in fn / struct / impl
127    RustLifetime,
128    /// `#[test] fn test_foo() { ... }`
129    RustTest,
130    // ── Python-specific (EX-3172 / V12b-1, populated by PythonParser) ──────
131    /// `def foo(...):` — top-level function
132    PythonFunction,
133    /// `def method(self, ...):` inside a class body
134    PythonMethod,
135    /// `class Foo:` — class definition
136    PythonClass,
137    /// `@dataclass`, `@property`, `@classmethod` etc. — decorator node
138    PythonDecorator,
139    /// `import x` or `from x import y` — import statement
140    PythonImport,
141    /// Top-level file module (file as a Python module)
142    PythonModule,
143    /// `lambda x: ...` — anonymous function expression
144    PythonLambda,
145    /// `async def foo():` — async function or method
146    PythonAsync,
147    /// Method decorated with `@property`
148    PythonProperty,
149}
150
151impl CodeNodeKind {
152    /// All 35 variants in stable order — used to build the Arrow Dictionary<Int8, Utf8>.
153    /// Int8 supports up to 127 unique values; 35 variants is well within range.
154    pub const ALL: [CodeNodeKind; 35] = [
155        // Language-agnostic (original set)
156        Self::File,
157        Self::Module,
158        Self::Class,
159        Self::Function,
160        Self::Method,
161        Self::Parameter,
162        Self::Variable,
163        Self::Test,
164        // Generic additions (EX-3168)
165        Self::Type,
166        Self::Constant,
167        Self::Import,
168        // Rust-specific (EX-3168)
169        Self::RustFn,
170        Self::RustMethod,
171        Self::RustImpl,
172        Self::RustTrait,
173        Self::RustStruct,
174        Self::RustEnum,
175        Self::RustMod,
176        Self::RustMacro,
177        Self::RustUse,
178        Self::RustConst,
179        Self::RustStatic,
180        Self::RustTypeAlias,
181        Self::RustAttribute,
182        Self::RustLifetime,
183        Self::RustTest,
184        // Python-specific (EX-3172)
185        Self::PythonFunction,
186        Self::PythonMethod,
187        Self::PythonClass,
188        Self::PythonDecorator,
189        Self::PythonImport,
190        Self::PythonModule,
191        Self::PythonLambda,
192        Self::PythonAsync,
193        Self::PythonProperty,
194    ];
195
196    pub fn as_str(self) -> &'static str {
197        match self {
198            Self::File => "file",
199            Self::Module => "module",
200            Self::Class => "class",
201            Self::Function => "function",
202            Self::Method => "method",
203            Self::Parameter => "parameter",
204            Self::Variable => "variable",
205            Self::Test => "test",
206            Self::Type => "type",
207            Self::Constant => "constant",
208            Self::Import => "import",
209            Self::RustFn => "rust_fn",
210            Self::RustMethod => "rust_method",
211            Self::RustImpl => "rust_impl",
212            Self::RustTrait => "rust_trait",
213            Self::RustStruct => "rust_struct",
214            Self::RustEnum => "rust_enum",
215            Self::RustMod => "rust_mod",
216            Self::RustMacro => "rust_macro",
217            Self::RustUse => "rust_use",
218            Self::RustConst => "rust_const",
219            Self::RustStatic => "rust_static",
220            Self::RustTypeAlias => "rust_type_alias",
221            Self::RustAttribute => "rust_attribute",
222            Self::RustLifetime => "rust_lifetime",
223            Self::RustTest => "rust_test",
224            Self::PythonFunction => "python_function",
225            Self::PythonMethod => "python_method",
226            Self::PythonClass => "python_class",
227            Self::PythonDecorator => "python_decorator",
228            Self::PythonImport => "python_import",
229            Self::PythonModule => "python_module",
230            Self::PythonLambda => "python_lambda",
231            Self::PythonAsync => "python_async",
232            Self::PythonProperty => "python_property",
233        }
234    }
235
236    pub fn parse(s: &str) -> Option<Self> {
237        match s {
238            "file" => Some(Self::File),
239            "module" => Some(Self::Module),
240            "class" => Some(Self::Class),
241            "function" => Some(Self::Function),
242            "method" => Some(Self::Method),
243            "parameter" => Some(Self::Parameter),
244            "variable" => Some(Self::Variable),
245            "test" => Some(Self::Test),
246            "type" => Some(Self::Type),
247            "constant" => Some(Self::Constant),
248            "import" => Some(Self::Import),
249            "rust_fn" => Some(Self::RustFn),
250            "rust_method" => Some(Self::RustMethod),
251            "rust_impl" => Some(Self::RustImpl),
252            "rust_trait" => Some(Self::RustTrait),
253            "rust_struct" => Some(Self::RustStruct),
254            "rust_enum" => Some(Self::RustEnum),
255            "rust_mod" => Some(Self::RustMod),
256            "rust_macro" => Some(Self::RustMacro),
257            "rust_use" => Some(Self::RustUse),
258            "rust_const" => Some(Self::RustConst),
259            "rust_static" => Some(Self::RustStatic),
260            "rust_type_alias" => Some(Self::RustTypeAlias),
261            "rust_attribute" => Some(Self::RustAttribute),
262            "rust_lifetime" => Some(Self::RustLifetime),
263            "rust_test" => Some(Self::RustTest),
264            "python_function" => Some(Self::PythonFunction),
265            "python_method" => Some(Self::PythonMethod),
266            "python_class" => Some(Self::PythonClass),
267            "python_decorator" => Some(Self::PythonDecorator),
268            "python_import" => Some(Self::PythonImport),
269            "python_module" => Some(Self::PythonModule),
270            "python_lambda" => Some(Self::PythonLambda),
271            "python_async" => Some(Self::PythonAsync),
272            "python_property" => Some(Self::PythonProperty),
273            _ => None,
274        }
275    }
276
277    /// Whether this kind is Rust-specific (introduced by EX-3168 / V12a-1).
278    pub fn is_rust_specific(self) -> bool {
279        matches!(
280            self,
281            Self::RustFn
282                | Self::RustMethod
283                | Self::RustImpl
284                | Self::RustTrait
285                | Self::RustStruct
286                | Self::RustEnum
287                | Self::RustMod
288                | Self::RustMacro
289                | Self::RustUse
290                | Self::RustConst
291                | Self::RustStatic
292                | Self::RustTypeAlias
293                | Self::RustAttribute
294                | Self::RustLifetime
295                | Self::RustTest
296        )
297    }
298
299    /// Whether this kind is Python-specific (introduced by EX-3172 / V12b-1).
300    pub fn is_python_specific(self) -> bool {
301        matches!(
302            self,
303            Self::PythonFunction
304                | Self::PythonMethod
305                | Self::PythonClass
306                | Self::PythonDecorator
307                | Self::PythonImport
308                | Self::PythonModule
309                | Self::PythonLambda
310                | Self::PythonAsync
311                | Self::PythonProperty
312        )
313    }
314}
315
316impl std::fmt::Display for CodeNodeKind {
317    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
318        f.write_str(self.as_str())
319    }
320}
321
322/// Kind of relationship between code objects.
323#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
324pub enum CodeEdgePredicate {
325    Calls,
326    InheritsFrom,
327    Imports,
328    Defines,
329    Uses,
330    Tests,
331    Contains,
332    References,
333    /// impl Trait for Struct → Trait node
334    ImplementsTrait,
335    /// pub use re-exports an item from another module
336    ReExports,
337    /// Macro invocation → macro definition
338    MacroExpands,
339    /// #[test] fn → function it tests (naming convention)
340    TestTargets,
341}
342
343impl CodeEdgePredicate {
344    pub const ALL: [CodeEdgePredicate; 12] = [
345        Self::Calls,
346        Self::InheritsFrom,
347        Self::Imports,
348        Self::Defines,
349        Self::Uses,
350        Self::Tests,
351        Self::Contains,
352        Self::References,
353        Self::ImplementsTrait,
354        Self::ReExports,
355        Self::MacroExpands,
356        Self::TestTargets,
357    ];
358
359    pub fn as_str(self) -> &'static str {
360        match self {
361            Self::Calls => "calls",
362            Self::InheritsFrom => "inherits_from",
363            Self::Imports => "imports",
364            Self::Defines => "defines",
365            Self::Uses => "uses",
366            Self::Tests => "tests",
367            Self::Contains => "contains",
368            Self::References => "references",
369            Self::ImplementsTrait => "implements_trait",
370            Self::ReExports => "re_exports",
371            Self::MacroExpands => "macro_expands",
372            Self::TestTargets => "test_targets",
373        }
374    }
375
376    pub fn parse(s: &str) -> Option<Self> {
377        match s {
378            "calls" => Some(Self::Calls),
379            "inherits_from" => Some(Self::InheritsFrom),
380            "imports" => Some(Self::Imports),
381            "defines" => Some(Self::Defines),
382            "uses" => Some(Self::Uses),
383            "tests" => Some(Self::Tests),
384            "contains" => Some(Self::Contains),
385            "references" => Some(Self::References),
386            "implements_trait" => Some(Self::ImplementsTrait),
387            "re_exports" => Some(Self::ReExports),
388            "macro_expands" => Some(Self::MacroExpands),
389            "test_targets" => Some(Self::TestTargets),
390            _ => None,
391        }
392    }
393}
394
395impl std::fmt::Display for CodeEdgePredicate {
396    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
397        f.write_str(self.as_str())
398    }
399}
400
401// ─── Utilities ──────────────────────────────────────────────────────────────
402
403/// Extract file path from a CodeNode ID.
404///
405/// IDs follow the pattern `prefix:path/to/file.py::Name`.
406/// This extracts `path/to/file.py`.
407///
408/// Examples:
409/// - `"func:brain/signal.py::fuse"` → `Some("brain/signal.py")`
410/// - `"mod:brain/signal.py"` → `Some("brain/signal.py")`
411/// - `"method:brain/store.py::Dual::get"` → `Some("brain/store.py")`
412pub fn extract_file_path(node_id: &str) -> Option<String> {
413    let rest = node_id.split_once(':')?.1;
414    let file_part = rest.split("::").next()?;
415    if file_part.is_empty() {
416        None
417    } else {
418        Some(file_part.to_string())
419    }
420}
421
422// ─── Schemas ────────────────────────────────────────────────────────────────
423
424/// Schema for the CodeNodes table.
425///
426/// Columns 0–12 (original):
427/// - `id`: fully-qualified identifier (e.g. `func:brain/perception/signal_fusion.py::fuse`)
428/// - `kind`: object type (dictionary-encoded string, `Dictionary<Int8, Utf8>`)
429/// - `parent_id`: containment pointer (function→class→module→file)
430/// - `name`: object name
431/// - `signature`: function/method signature
432/// - `docstring`: documentation text
433/// - `body_hash`: SHA-256 of the object body for equality checks
434/// - `body`: full source text of the node
435/// - `embedding`: semantic vector (`FixedSizeList<f32, 768>`)
436/// - `loc`: lines of code
437/// - `cyclomatic_complexity`: complexity metric
438/// - `coverage_pct`: test coverage percentage
439/// - `last_modified`: timestamp from git history
440///
441/// Columns 13–18 (EX-3168 / V12a-1 — position metadata, all nullable):
442/// - `start_line`: 1-indexed source line where the node begins
443/// - `end_line`: 1-indexed source line where the node ends
444/// - `start_col`: 0-indexed column within `start_line`
445/// - `end_col`: 0-indexed column within `end_line`
446/// - `file_path`: relative path from crate root (e.g. `"src/lib.rs"`)
447/// - `byte_offset`: byte position from file start (for fast editor seek)
448///
449/// Position columns are nullable for backward compatibility with existing Parquet files.
450/// The tree-sitter Rust parser (EX-3120) populates them for new nodes.
451pub fn code_nodes_schema() -> Schema {
452    Schema::new(vec![
453        // ── Core identity ──────────────────────────────────────────────────
454        Field::new("id", DataType::Utf8, false),
455        Field::new(
456            "kind",
457            DataType::Dictionary(Box::new(DataType::Int8), Box::new(DataType::Utf8)),
458            false,
459        ),
460        Field::new("parent_id", DataType::Utf8, true),
461        Field::new("name", DataType::Utf8, false),
462        Field::new("signature", DataType::Utf8, true),
463        Field::new("docstring", DataType::Utf8, true),
464        Field::new("body_hash", DataType::Utf8, true),
465        Field::new("body", DataType::LargeUtf8, true),
466        // ── Embeddings ─────────────────────────────────────────────────────
467        Field::new(
468            "embedding",
469            DataType::FixedSizeList(
470                Arc::new(Field::new("item", DataType::Float32, false)),
471                CODE_EMBEDDING_DIM,
472            ),
473            true,
474        ),
475        // ── Metrics ────────────────────────────────────────────────────────
476        Field::new("loc", DataType::Int32, true),
477        Field::new("cyclomatic_complexity", DataType::Int32, true),
478        Field::new("coverage_pct", DataType::Float64, true),
479        Field::new(
480            "last_modified",
481            DataType::Timestamp(TimeUnit::Millisecond, Some("UTC".into())),
482            true,
483        ),
484        // ── Position metadata (EX-3168 / V12a-1) — nullable ───────────────
485        Field::new("start_line", DataType::UInt32, true),
486        Field::new("end_line", DataType::UInt32, true),
487        Field::new("start_col", DataType::UInt32, true),
488        Field::new("end_col", DataType::UInt32, true),
489        Field::new("file_path", DataType::Utf8, true),
490        Field::new("byte_offset", DataType::UInt64, true),
491    ])
492}
493
494/// Schema for the CodeEdges table.
495///
496/// Columns:
497/// - `source_id`: source CodeNode ID
498/// - `target_id`: target CodeNode ID
499/// - `predicate`: relationship type (dictionary-encoded string)
500/// - `weight`: optional edge weight (call frequency, inheritance depth, etc.)
501/// - `commit_id`: which commit introduced this edge
502pub fn code_edges_schema() -> Schema {
503    Schema::new(vec![
504        Field::new("source_id", DataType::Utf8, false),
505        Field::new("target_id", DataType::Utf8, false),
506        Field::new(
507            "predicate",
508            DataType::Dictionary(Box::new(DataType::Int8), Box::new(DataType::Utf8)),
509            false,
510        ),
511        Field::new("weight", DataType::Float32, true),
512        Field::new("commit_id", DataType::Utf8, true),
513    ])
514}
515
516// ─── RecordBatch builders ───────────────────────────────────────────────────
517
518use arrow::array::{
519    Float32Array, Float64Array, Int8Array, Int32Array, RecordBatch, StringArray, UInt32Array,
520    UInt64Array,
521};
522
523/// A code node record for batch building.
524///
525/// Position fields (`start_line` through `byte_offset`) were added in EX-3168 / V12a-1.
526/// Existing call sites that don't set them should use `..Default::default()` to get `None`
527/// for all position fields. The `Default` impl uses `CodeNodeKind::File` as the kind
528/// placeholder — callers must always set `id`, `kind`, and `name` explicitly.
529#[derive(Debug, Clone)]
530pub struct CodeNode {
531    pub id: String,
532    pub kind: CodeNodeKind,
533    pub parent_id: Option<String>,
534    pub name: String,
535    pub signature: Option<String>,
536    pub docstring: Option<String>,
537    pub body_hash: Option<String>,
538    pub body: Option<String>,
539    pub loc: Option<i32>,
540    pub cyclomatic_complexity: Option<i32>,
541    pub coverage_pct: Option<f64>,
542    pub last_modified: Option<i64>,
543    // Position metadata (EX-3168 / V12a-1) — None for nodes from old data or Python parser
544    pub start_line: Option<u32>,
545    pub end_line: Option<u32>,
546    pub start_col: Option<u32>,
547    pub end_col: Option<u32>,
548    pub file_path: Option<String>,
549    pub byte_offset: Option<u64>,
550}
551
552impl Default for CodeNode {
553    fn default() -> Self {
554        Self {
555            id: String::new(),
556            kind: CodeNodeKind::File,
557            parent_id: None,
558            name: String::new(),
559            signature: None,
560            docstring: None,
561            body_hash: None,
562            body: None,
563            loc: None,
564            cyclomatic_complexity: None,
565            coverage_pct: None,
566            last_modified: None,
567            start_line: None,
568            end_line: None,
569            start_col: None,
570            end_col: None,
571            file_path: None,
572            byte_offset: None,
573        }
574    }
575}
576
577/// A code edge record for batch building.
578#[derive(Debug, Clone)]
579pub struct CodeEdge {
580    pub source_id: String,
581    pub target_id: String,
582    pub predicate: CodeEdgePredicate,
583    pub weight: Option<f32>,
584    pub commit_id: Option<String>,
585}
586
587/// Build a RecordBatch of CodeNodes (without embeddings — those are added later).
588pub fn build_code_nodes_batch(nodes: &[CodeNode]) -> Result<RecordBatch, arrow::error::ArrowError> {
589    use arrow::array::{Int8DictionaryArray, PrimitiveBuilder};
590
591    let schema = Arc::new(code_nodes_schema());
592    let n = nodes.len();
593
594    if n == 0 {
595        return Ok(RecordBatch::new_empty(schema));
596    }
597
598    let ids: Vec<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
599    let names: Vec<&str> = nodes.iter().map(|n| n.name.as_str()).collect();
600    let parent_ids: Vec<Option<&str>> = nodes.iter().map(|n| n.parent_id.as_deref()).collect();
601    let signatures: Vec<Option<&str>> = nodes.iter().map(|n| n.signature.as_deref()).collect();
602    let docstrings: Vec<Option<&str>> = nodes.iter().map(|n| n.docstring.as_deref()).collect();
603    let body_hashes: Vec<Option<&str>> = nodes.iter().map(|n| n.body_hash.as_deref()).collect();
604    let bodies: Vec<Option<&str>> = nodes.iter().map(|n| n.body.as_deref()).collect();
605
606    // Build kind as dictionary array
607    let kind_keys = Int8Array::from(
608        nodes
609            .iter()
610            .map(|n| {
611                CodeNodeKind::ALL
612                    .iter()
613                    .position(|k| *k == n.kind)
614                    .expect("valid kind") as i8
615            })
616            .collect::<Vec<i8>>(),
617    );
618    let kind_values = StringArray::from(
619        CodeNodeKind::ALL
620            .iter()
621            .map(|k| k.as_str())
622            .collect::<Vec<_>>(),
623    );
624    let kind_dict = Int8DictionaryArray::try_new(kind_keys, Arc::new(kind_values))?;
625
626    // Embedding: null for all (populated later by embedding pipeline)
627    let embedding_field = Arc::new(Field::new("item", DataType::Float32, false));
628    let null_embedding = arrow::array::FixedSizeListArray::try_new(
629        embedding_field,
630        CODE_EMBEDDING_DIM,
631        Arc::new(Float32Array::from(vec![
632            0.0f32;
633            n * CODE_EMBEDDING_DIM as usize
634        ])),
635        Some(arrow::buffer::NullBuffer::new(
636            arrow::buffer::BooleanBuffer::from(vec![false; n]),
637        )),
638    )?;
639
640    // Metrics
641    let locs: Vec<Option<i32>> = nodes.iter().map(|n| n.loc).collect();
642    let complexities: Vec<Option<i32>> = nodes.iter().map(|n| n.cyclomatic_complexity).collect();
643    let coverages: Vec<Option<f64>> = nodes.iter().map(|n| n.coverage_pct).collect();
644
645    let mut last_mod_builder =
646        PrimitiveBuilder::<arrow::datatypes::TimestampMillisecondType>::new().with_timezone("UTC");
647    for node in nodes {
648        match node.last_modified {
649            Some(ts) => last_mod_builder.append_value(ts),
650            None => last_mod_builder.append_null(),
651        }
652    }
653
654    // Position metadata (EX-3168 / V12a-1) — nullable; None for nodes without position info
655    let start_lines: Vec<Option<u32>> = nodes.iter().map(|n| n.start_line).collect();
656    let end_lines: Vec<Option<u32>> = nodes.iter().map(|n| n.end_line).collect();
657    let start_cols: Vec<Option<u32>> = nodes.iter().map(|n| n.start_col).collect();
658    let end_cols: Vec<Option<u32>> = nodes.iter().map(|n| n.end_col).collect();
659    let file_paths: Vec<Option<&str>> = nodes.iter().map(|n| n.file_path.as_deref()).collect();
660    let byte_offsets: Vec<Option<u64>> = nodes.iter().map(|n| n.byte_offset).collect();
661
662    RecordBatch::try_new(
663        schema,
664        vec![
665            Arc::new(StringArray::from(ids)),
666            Arc::new(kind_dict),
667            Arc::new(StringArray::from(parent_ids)),
668            Arc::new(StringArray::from(names)),
669            Arc::new(StringArray::from(signatures)),
670            Arc::new(StringArray::from(docstrings)),
671            Arc::new(StringArray::from(body_hashes)),
672            Arc::new(arrow::array::LargeStringArray::from(bodies)),
673            Arc::new(null_embedding),
674            Arc::new(Int32Array::from(locs)),
675            Arc::new(Int32Array::from(complexities)),
676            Arc::new(Float64Array::from(coverages)),
677            Arc::new(last_mod_builder.finish()),
678            Arc::new(UInt32Array::from(start_lines)),
679            Arc::new(UInt32Array::from(end_lines)),
680            Arc::new(UInt32Array::from(start_cols)),
681            Arc::new(UInt32Array::from(end_cols)),
682            Arc::new(StringArray::from(file_paths)),
683            Arc::new(UInt64Array::from(byte_offsets)),
684        ],
685    )
686}
687
688/// Build a RecordBatch of CodeEdges.
689pub fn build_code_edges_batch(edges: &[CodeEdge]) -> Result<RecordBatch, arrow::error::ArrowError> {
690    use arrow::array::Int8DictionaryArray;
691
692    let schema = Arc::new(code_edges_schema());
693    let n = edges.len();
694
695    if n == 0 {
696        return Ok(RecordBatch::new_empty(schema));
697    }
698
699    let source_ids: Vec<&str> = edges.iter().map(|e| e.source_id.as_str()).collect();
700    let target_ids: Vec<&str> = edges.iter().map(|e| e.target_id.as_str()).collect();
701    let weights: Vec<Option<f32>> = edges.iter().map(|e| e.weight).collect();
702    let commit_ids: Vec<Option<&str>> = edges.iter().map(|e| e.commit_id.as_deref()).collect();
703
704    // Build predicate as dictionary array
705    let pred_keys = Int8Array::from(
706        edges
707            .iter()
708            .map(|e| {
709                CodeEdgePredicate::ALL
710                    .iter()
711                    .position(|p| *p == e.predicate)
712                    .expect("valid predicate") as i8
713            })
714            .collect::<Vec<i8>>(),
715    );
716    let pred_values = StringArray::from(
717        CodeEdgePredicate::ALL
718            .iter()
719            .map(|p| p.as_str())
720            .collect::<Vec<_>>(),
721    );
722    let pred_dict = Int8DictionaryArray::try_new(pred_keys, Arc::new(pred_values))?;
723
724    RecordBatch::try_new(
725        schema,
726        vec![
727            Arc::new(StringArray::from(source_ids)),
728            Arc::new(StringArray::from(target_ids)),
729            Arc::new(pred_dict),
730            Arc::new(Float32Array::from(weights)),
731            Arc::new(StringArray::from(commit_ids)),
732        ],
733    )
734}
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739    use arrow::array::Array;
740
741    #[test]
742    fn test_code_nodes_schema_field_count() {
743        let schema = code_nodes_schema();
744        assert_eq!(schema.fields().len(), 19);
745    }
746
747    #[test]
748    fn test_code_edges_schema_field_count() {
749        let schema = code_edges_schema();
750        assert_eq!(schema.fields().len(), 5);
751    }
752
753    #[test]
754    fn test_code_node_kind_roundtrip() {
755        for kind in CodeNodeKind::ALL {
756            let s = kind.as_str();
757            let parsed = CodeNodeKind::parse(s).expect("should parse");
758            assert_eq!(parsed, kind);
759        }
760    }
761
762    #[test]
763    fn test_code_edge_predicate_roundtrip() {
764        for pred in CodeEdgePredicate::ALL {
765            let s = pred.as_str();
766            let parsed = CodeEdgePredicate::parse(s).expect("should parse");
767            assert_eq!(parsed, pred);
768        }
769    }
770
771    #[test]
772    fn test_build_code_nodes_batch() {
773        let nodes = vec![
774            CodeNode {
775                id: "func:brain/main.py::main".to_string(),
776                kind: CodeNodeKind::Function,
777                parent_id: Some("mod:brain/main.py".to_string()),
778                name: "main".to_string(),
779                signature: Some("def main() -> None".to_string()),
780                docstring: Some("Entry point.".to_string()),
781                body_hash: Some("abc123".to_string()),
782                body: Some("def main():\n    pass".to_string()),
783                loc: Some(42),
784                cyclomatic_complexity: Some(5),
785                coverage_pct: Some(0.85),
786                last_modified: None,
787                ..Default::default()
788            },
789            CodeNode {
790                id: "class:brain/store.py::Store".to_string(),
791                kind: CodeNodeKind::Class,
792                parent_id: Some("mod:brain/store.py".to_string()),
793                name: "Store".to_string(),
794                signature: None,
795                docstring: Some("Main store class.".to_string()),
796                body_hash: Some("def456".to_string()),
797                body: Some("class Store:\n    pass".to_string()),
798                loc: Some(120),
799                cyclomatic_complexity: Some(12),
800                coverage_pct: None,
801                last_modified: Some(chrono::Utc::now().timestamp_millis()),
802                ..Default::default()
803            },
804        ];
805
806        let batch = build_code_nodes_batch(&nodes).expect("should build batch");
807        assert_eq!(batch.num_rows(), 2);
808        assert_eq!(batch.num_columns(), 19);
809
810        // Verify id column
811        let ids = batch
812            .column(node_col::ID)
813            .as_any()
814            .downcast_ref::<StringArray>()
815            .expect("id column");
816        assert_eq!(ids.value(0), "func:brain/main.py::main");
817        assert_eq!(ids.value(1), "class:brain/store.py::Store");
818
819        // Verify kind is dictionary-encoded
820        assert!(matches!(
821            batch.column(node_col::KIND).data_type(),
822            DataType::Dictionary(_, _)
823        ));
824
825        // Verify embedding column is all nulls (not yet populated)
826        let emb = batch.column(node_col::EMBEDDING);
827        assert!(emb.is_null(0));
828        assert!(emb.is_null(1));
829
830        // Verify metrics
831        let locs = batch
832            .column(node_col::LOC)
833            .as_any()
834            .downcast_ref::<Int32Array>()
835            .expect("loc column");
836        assert_eq!(locs.value(0), 42);
837        assert_eq!(locs.value(1), 120);
838    }
839
840    #[test]
841    fn test_build_code_edges_batch() {
842        let edges = vec![
843            CodeEdge {
844                source_id: "func:a.py::foo".to_string(),
845                target_id: "func:b.py::bar".to_string(),
846                predicate: CodeEdgePredicate::Calls,
847                weight: Some(1.0),
848                commit_id: Some("abc123".to_string()),
849            },
850            CodeEdge {
851                source_id: "class:c.py::Child".to_string(),
852                target_id: "class:c.py::Parent".to_string(),
853                predicate: CodeEdgePredicate::InheritsFrom,
854                weight: None,
855                commit_id: None,
856            },
857            CodeEdge {
858                source_id: "mod:a.py".to_string(),
859                target_id: "mod:b.py".to_string(),
860                predicate: CodeEdgePredicate::Imports,
861                weight: None,
862                commit_id: None,
863            },
864        ];
865
866        let batch = build_code_edges_batch(&edges).expect("should build batch");
867        assert_eq!(batch.num_rows(), 3);
868        assert_eq!(batch.num_columns(), 5);
869
870        // Verify predicate is dictionary-encoded
871        assert!(matches!(
872            batch.column(edge_col::PREDICATE).data_type(),
873            DataType::Dictionary(_, _)
874        ));
875
876        // Verify source/target
877        let sources = batch
878            .column(edge_col::SOURCE_ID)
879            .as_any()
880            .downcast_ref::<StringArray>()
881            .expect("source_id column");
882        assert_eq!(sources.value(0), "func:a.py::foo");
883        assert_eq!(sources.value(2), "mod:a.py");
884    }
885
886    #[test]
887    fn test_empty_batches() {
888        let nodes_batch = build_code_nodes_batch(&[]).expect("empty nodes batch");
889        assert_eq!(nodes_batch.num_rows(), 0);
890        assert_eq!(nodes_batch.num_columns(), 19);
891
892        let edges_batch = build_code_edges_batch(&[]).expect("empty edges batch");
893        assert_eq!(edges_batch.num_rows(), 0);
894        assert_eq!(edges_batch.num_columns(), 5);
895    }
896
897    // ── EX-3168 / V12a-1 tests ───────────────────────────────────────────────
898
899    #[test]
900    fn test_all_35_code_node_kind_variants_roundtrip() {
901        // Every variant in ALL must survive as_str() → parse() roundtrip
902        assert_eq!(CodeNodeKind::ALL.len(), 35, "expected 35 variants in ALL");
903        for kind in CodeNodeKind::ALL {
904            let s = kind.as_str();
905            let parsed = CodeNodeKind::parse(s)
906                .unwrap_or_else(|| panic!("parse({s:?}) returned None for {kind:?}"));
907            assert_eq!(parsed, kind, "roundtrip failed for {kind:?}");
908        }
909    }
910
911    #[test]
912    fn test_rust_specific_variants_are_flagged() {
913        let rust_kinds = [
914            CodeNodeKind::RustFn,
915            CodeNodeKind::RustMethod,
916            CodeNodeKind::RustImpl,
917            CodeNodeKind::RustTrait,
918            CodeNodeKind::RustStruct,
919            CodeNodeKind::RustEnum,
920            CodeNodeKind::RustMod,
921            CodeNodeKind::RustMacro,
922            CodeNodeKind::RustUse,
923            CodeNodeKind::RustConst,
924            CodeNodeKind::RustStatic,
925            CodeNodeKind::RustTypeAlias,
926            CodeNodeKind::RustAttribute,
927            CodeNodeKind::RustLifetime,
928            CodeNodeKind::RustTest,
929        ];
930        assert_eq!(rust_kinds.len(), 15, "expected 15 Rust-specific variants");
931        for k in rust_kinds {
932            assert!(k.is_rust_specific(), "{k:?} should be rust-specific");
933        }
934
935        // Generic and language-agnostic variants must NOT be flagged
936        let generic_kinds = [
937            CodeNodeKind::File,
938            CodeNodeKind::Module,
939            CodeNodeKind::Class,
940            CodeNodeKind::Function,
941            CodeNodeKind::Method,
942            CodeNodeKind::Parameter,
943            CodeNodeKind::Variable,
944            CodeNodeKind::Test,
945            CodeNodeKind::Type,
946            CodeNodeKind::Constant,
947            CodeNodeKind::Import,
948        ];
949        for k in generic_kinds {
950            assert!(!k.is_rust_specific(), "{k:?} should NOT be rust-specific");
951        }
952    }
953
954    #[test]
955    fn test_rust_kind_strings_have_rust_prefix() {
956        let rust_kinds = CodeNodeKind::ALL
957            .iter()
958            .filter(|k| k.is_rust_specific())
959            .collect::<Vec<_>>();
960        assert!(!rust_kinds.is_empty());
961        for k in rust_kinds {
962            assert!(
963                k.as_str().starts_with("rust_"),
964                "Rust-specific kind {k:?} string {:?} must start with 'rust_'",
965                k.as_str()
966            );
967        }
968    }
969
970    #[test]
971    fn test_position_columns_are_in_schema() {
972        let schema = code_nodes_schema();
973        let field_names: Vec<&str> = schema.fields().iter().map(|f| f.name().as_str()).collect();
974        assert!(field_names.contains(&"start_line"), "missing start_line");
975        assert!(field_names.contains(&"end_line"), "missing end_line");
976        assert!(field_names.contains(&"start_col"), "missing start_col");
977        assert!(field_names.contains(&"end_col"), "missing end_col");
978        assert!(field_names.contains(&"file_path"), "missing file_path");
979        assert!(field_names.contains(&"byte_offset"), "missing byte_offset");
980    }
981
982    #[test]
983    fn test_position_columns_are_nullable() {
984        let schema = code_nodes_schema();
985        for name in &[
986            "start_line",
987            "end_line",
988            "start_col",
989            "end_col",
990            "file_path",
991            "byte_offset",
992        ] {
993            let field = schema
994                .field_with_name(name)
995                .unwrap_or_else(|_| panic!("{name} not in schema"));
996            assert!(
997                field.is_nullable(),
998                "{name} must be nullable for backward compatibility"
999            );
1000        }
1001    }
1002
1003    #[test]
1004    fn test_position_columns_correct_types() {
1005        use arrow::datatypes::DataType;
1006        let schema = code_nodes_schema();
1007        let check = |name: &str, expected: DataType| {
1008            let field = schema.field_with_name(name).expect(name);
1009            assert_eq!(
1010                *field.data_type(),
1011                expected,
1012                "{name} has wrong type: expected {expected:?}"
1013            );
1014        };
1015        check("start_line", DataType::UInt32);
1016        check("end_line", DataType::UInt32);
1017        check("start_col", DataType::UInt32);
1018        check("end_col", DataType::UInt32);
1019        check("file_path", DataType::Utf8);
1020        check("byte_offset", DataType::UInt64);
1021    }
1022
1023    #[test]
1024    fn test_position_column_indices_match_schema() {
1025        // Named constants must match actual column positions in the schema
1026        let schema = code_nodes_schema();
1027        let check = |idx: usize, expected_name: &str| {
1028            let actual = schema.field(idx).name();
1029            assert_eq!(
1030                actual, expected_name,
1031                "node_col constant {idx} expected {expected_name}, got {actual}"
1032            );
1033        };
1034        check(node_col::START_LINE, "start_line");
1035        check(node_col::END_LINE, "end_line");
1036        check(node_col::START_COL, "start_col");
1037        check(node_col::END_COL, "end_col");
1038        check(node_col::FILE_PATH, "file_path");
1039        check(node_col::BYTE_OFFSET, "byte_offset");
1040    }
1041
1042    #[test]
1043    fn test_build_node_with_position_metadata() {
1044        let nodes = vec![
1045            CodeNode {
1046                id: "rust_fn:src/lib.rs::process".to_string(),
1047                kind: CodeNodeKind::RustFn,
1048                parent_id: Some("rust_mod:src/lib.rs".to_string()),
1049                name: "process".to_string(),
1050                signature: Some("fn process(input: &str) -> Result<()>".to_string()),
1051                start_line: Some(42),
1052                end_line: Some(58),
1053                start_col: Some(0),
1054                end_col: Some(1),
1055                file_path: Some("src/lib.rs".to_string()),
1056                byte_offset: Some(1024),
1057                loc: Some(17),
1058                ..Default::default()
1059            },
1060            // Node without position (backward compat — all position fields None)
1061            CodeNode {
1062                id: "func:brain/old.py::legacy".to_string(),
1063                kind: CodeNodeKind::Function,
1064                name: "legacy".to_string(),
1065                ..Default::default()
1066            },
1067        ];
1068
1069        let batch = build_code_nodes_batch(&nodes).expect("should build batch");
1070        assert_eq!(batch.num_rows(), 2);
1071        assert_eq!(batch.num_columns(), 19);
1072
1073        // Row 0: position is present
1074        let start_lines = batch
1075            .column(node_col::START_LINE)
1076            .as_any()
1077            .downcast_ref::<UInt32Array>()
1078            .expect("start_line column");
1079        assert!(!start_lines.is_null(0), "row 0 start_line should be set");
1080        assert_eq!(start_lines.value(0), 42);
1081
1082        let file_paths = batch
1083            .column(node_col::FILE_PATH)
1084            .as_any()
1085            .downcast_ref::<StringArray>()
1086            .expect("file_path column");
1087        assert_eq!(file_paths.value(0), "src/lib.rs");
1088
1089        // Row 1: position is null (backward compat)
1090        assert!(start_lines.is_null(1), "row 1 start_line should be null");
1091        assert!(file_paths.is_null(1), "row 1 file_path should be null");
1092
1093        // Rust kind is dictionary-encoded
1094        assert!(matches!(
1095            batch.column(node_col::KIND).data_type(),
1096            DataType::Dictionary(_, _)
1097        ));
1098    }
1099
1100    #[test]
1101    fn test_dictionary_encoding_roundtrip_all_kinds() {
1102        use arrow::array::Int8DictionaryArray;
1103
1104        // Build a batch with one node per kind in ALL
1105        let nodes: Vec<CodeNode> = CodeNodeKind::ALL
1106            .iter()
1107            .enumerate()
1108            .map(|(i, &kind)| CodeNode {
1109                id: format!("node_{i}"),
1110                kind,
1111                name: format!("item_{i}"),
1112                ..Default::default()
1113            })
1114            .collect();
1115
1116        let batch = build_code_nodes_batch(&nodes).expect("batch with all 35 kinds");
1117        assert_eq!(batch.num_rows(), 35);
1118
1119        // Extract kind column and verify each value decodes correctly
1120        let kind_col = batch
1121            .column(node_col::KIND)
1122            .as_any()
1123            .downcast_ref::<Int8DictionaryArray>()
1124            .expect("kind is Int8DictionaryArray");
1125        let kind_values = kind_col
1126            .values()
1127            .as_any()
1128            .downcast_ref::<StringArray>()
1129            .expect("kind values are strings");
1130
1131        for (i, &expected_kind) in CodeNodeKind::ALL.iter().enumerate() {
1132            let key = kind_col.keys().value(i) as usize;
1133            let decoded_str = kind_values.value(key);
1134            assert_eq!(
1135                decoded_str,
1136                expected_kind.as_str(),
1137                "row {i}: expected {:?}, got {decoded_str:?}",
1138                expected_kind.as_str()
1139            );
1140        }
1141    }
1142
1143    // ── EX-3172 / V12b-1 tests ───────────────────────────────────────────────
1144
1145    #[test]
1146    fn test_python_specific_variants_are_flagged() {
1147        let python_kinds = [
1148            CodeNodeKind::PythonFunction,
1149            CodeNodeKind::PythonMethod,
1150            CodeNodeKind::PythonClass,
1151            CodeNodeKind::PythonDecorator,
1152            CodeNodeKind::PythonImport,
1153            CodeNodeKind::PythonModule,
1154            CodeNodeKind::PythonLambda,
1155            CodeNodeKind::PythonAsync,
1156            CodeNodeKind::PythonProperty,
1157        ];
1158        assert_eq!(python_kinds.len(), 9, "expected 9 Python-specific variants");
1159        for k in python_kinds {
1160            assert!(k.is_python_specific(), "{k:?} should be python-specific");
1161        }
1162
1163        // Generic, Rust, and language-agnostic variants must NOT be flagged
1164        let non_python = [
1165            CodeNodeKind::File,
1166            CodeNodeKind::Module,
1167            CodeNodeKind::Class,
1168            CodeNodeKind::Function,
1169            CodeNodeKind::Method,
1170            CodeNodeKind::RustFn,
1171            CodeNodeKind::RustStruct,
1172            CodeNodeKind::Import,
1173        ];
1174        for k in non_python {
1175            assert!(
1176                !k.is_python_specific(),
1177                "{k:?} should NOT be python-specific"
1178            );
1179        }
1180    }
1181
1182    #[test]
1183    fn test_python_kind_strings_have_python_prefix() {
1184        let python_kinds = CodeNodeKind::ALL
1185            .iter()
1186            .filter(|k| k.is_python_specific())
1187            .collect::<Vec<_>>();
1188        assert_eq!(python_kinds.len(), 9);
1189        for k in python_kinds {
1190            assert!(
1191                k.as_str().starts_with("python_"),
1192                "Python-specific kind {k:?} string {:?} must start with 'python_'",
1193                k.as_str()
1194            );
1195        }
1196    }
1197
1198    #[test]
1199    fn test_python_variants_roundtrip() {
1200        let python_kinds = [
1201            CodeNodeKind::PythonFunction,
1202            CodeNodeKind::PythonMethod,
1203            CodeNodeKind::PythonClass,
1204            CodeNodeKind::PythonDecorator,
1205            CodeNodeKind::PythonImport,
1206            CodeNodeKind::PythonModule,
1207            CodeNodeKind::PythonLambda,
1208            CodeNodeKind::PythonAsync,
1209            CodeNodeKind::PythonProperty,
1210        ];
1211        for k in python_kinds {
1212            let s = k.as_str();
1213            let parsed = CodeNodeKind::parse(s)
1214                .unwrap_or_else(|| panic!("parse({s:?}) returned None for {k:?}"));
1215            assert_eq!(parsed, k, "roundtrip failed for {k:?}");
1216        }
1217    }
1218}