Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
372 changes: 351 additions & 21 deletions crates/rskim-search/src/ast_index/extract.rs

Large diffs are not rendered by default.

19 changes: 12 additions & 7 deletions crates/rskim-search/src/ast_index/extract_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use rskim_core::Language;

use super::*;
use crate::ast_index::structural::BUCKET_LABEL_BASE;
use crate::ast_index::structural::is_synthetic_id;
use crate::ast_index::{AstBigram, AstTrigram, DEFAULT_AST_WEIGHT, linearize_source, vocab_lookup};

// ── Helpers ──────────────────────────────────────────────────────────────────
Expand All @@ -21,9 +21,15 @@ fn unit_trigram_weight(_: AstTrigram) -> f32 {
1.0
}

/// Build a `LinearNode` with given kind_id and depth.
/// Build a `LinearNode` with given kind_id and depth. `start_line`/`start_byte`
/// (AD-394-6) default to 0 — these structural tests only assert on n-gram keys
/// and metrics, never on recovered line/byte positions.
fn node(kind_id: u16, depth: u16) -> LinearNode {
LinearNode { kind_id, depth }
LinearNode {
kind_id,
depth,
..Default::default()
}
}

/// Collect bigram keys from a result set for membership assertions.
Expand Down Expand Up @@ -697,9 +703,8 @@ fn trigram_count_accumulates_for_repeated_triple() {
// This test runs both functions on the same input (a multi-node tree that
// exercises gap-fill, siblings, and depth nesting) and asserts that the real
// bigrams and trigrams match exactly after filtering out all synthetic markers
// (parent or child ID >= BUCKET_LABEL_BASE = 64900).
// via `is_synthetic_id` (AD-394-3: parent or child ID >= BUCKET_LABEL_BASE = 64900).
//
// Filtering: a bigram is synthetic when parent >= 64900 OR child >= 64900.
// Trigrams cannot be synthetic (synthetic IDs never appear in trigram slots).

#[test]
Expand All @@ -721,7 +726,7 @@ fn metrics_path_real_ngrams_match_weights_path() {

let is_real_bigram = |e: &AstBigramEntry| {
let (parent, child) = e.ngram.decode();
parent < BUCKET_LABEL_BASE && child < BUCKET_LABEL_BASE
!is_synthetic_id(parent) && !is_synthetic_id(child)
};

let mut weights_bigram_keys: Vec<u32> = weights_result
Expand Down Expand Up @@ -786,7 +791,7 @@ fn metrics_path_u16_max_depth_no_panic_max_depth_recorded() {

let is_real_bigram = |e: &AstBigramEntry| {
let (parent, child) = e.ngram.decode();
parent < BUCKET_LABEL_BASE && child < BUCKET_LABEL_BASE
!is_synthetic_id(parent) && !is_synthetic_id(child)
};
let real_bigrams: Vec<_> = result
.bigrams
Expand Down
26 changes: 25 additions & 1 deletion crates/rskim-search/src/ast_index/linearize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,31 @@ const MAX_FILE_SIZE_LARGE: usize = 1024 * 1024;
/// to resolve the string. A `kind_id` of `0` (sentinel) means the grammar
/// kind was not found in the vocabulary (unknown kind).
///
/// `depth` is the 0-indexed traversal depth from the root. Parent–child
/// `depth` is the 0-indexed depth from the root. Parent–child
/// relationships are recoverable: a node's parent is the nearest preceding
/// node with `depth == self.depth - 1`.
///
/// # AD-394-6: `start_line`/`start_byte` are transient, never persisted
///
/// Added for OD-394-1 (synthetic-marker line recovery): `start_line` (1-indexed)
/// and `start_byte` carry the node's source position so `extract.rs` can record
/// a representative position per emitted synthetic marker in the SAME traversal
/// that emits it (ADR-006 — one pass, no second detection re-implementation).
/// `LinearNode` is a transient in-memory intermediate — only n-gram postings and
/// per-file `StructuralMetrics` are ever serialized (`store/builder.rs`,
/// `store/format.rs FORMAT_VERSION=2`) — so this addition does NOT change the
/// on-disk format, bump `FORMAT_VERSION`, or trigger a rebuild/self-heal.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct LinearNode {
/// Vocabulary ID into `NODE_KIND_VOCABULARY`. `0` is the sentinel for
/// unknown kinds.
pub kind_id: NodeKindId,
/// 0-indexed depth from the tree root (root node is depth 0).
pub depth: u16,
/// 1-indexed source line the node starts on (AD-394-6). Transient/unpersisted.
pub start_line: u32,
/// Byte offset the node starts at (AD-394-6). Transient/unpersisted.
pub start_byte: u32,
}

/// Result of linearizing a single source file.
Expand Down Expand Up @@ -269,9 +284,18 @@ fn linearize_tree(tree: &tree_sitter::Tree, lang_map: &[Option<u16>]) -> Lineari
// saturating_cast is the correct pattern for converting u32 → u16.
#[allow(clippy::cast_possible_truncation)]
let depth = item.depth.min(u32::from(u16::MAX)) as u16;
// AD-394-6 / PF-004: widen usize → u32 BEFORE saturating_add(1) for
// 1-indexing — mirrors reparse.rs's recover_line row→line conversion.
// No u16 depth arithmetic is introduced here (PF-004 is about depth
// comparisons; this is an independent source-position field).
let row = item.node.start_position().row;
let start_line = u32::try_from(row).unwrap_or(u32::MAX).saturating_add(1);
let start_byte = u32::try_from(item.node.start_byte()).unwrap_or(u32::MAX);
nodes.push(LinearNode {
kind_id: vocab_id,
depth,
start_line,
start_byte,
});
}

Expand Down
59 changes: 59 additions & 0 deletions crates/rskim-search/src/ast_index/linearize_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,72 @@ fn linear_node_is_copy() {
let n = LinearNode {
kind_id: 42,
depth: 3,
start_line: 7,
start_byte: 100,
};
let copy = n;
assert_eq!(copy.kind_id, 42);
assert_eq!(copy.depth, 3);
assert_eq!(n.kind_id, 42); // using n after assignment proves Copy
}

// ── AD-394-6: start_line / start_byte population ─────────────────────────────

#[test]
fn linear_node_default_has_zero_line_and_byte() {
let n = LinearNode::default();
assert_eq!(n.start_line, 0);
assert_eq!(n.start_byte, 0);
}

#[test]
fn start_line_is_one_indexed_and_monotonic_in_pre_order() {
// "fn main() {}" — root starts at line 1 (1-indexed), byte 0.
let result = parse_and_linearize("fn main() {}", Language::Rust);
let root = &result.nodes[0];
assert_eq!(
root.start_line, 1,
"root node must start on 1-indexed line 1; got {}",
root.start_line
);
assert_eq!(
root.start_byte, 0,
"root node must start at byte 0; got {}",
root.start_byte
);

// Pre-order traversal visits nodes in ascending start-byte order, so
// start_byte must be non-decreasing across the sequence.
for pair in result.nodes.windows(2) {
assert!(
pair[0].start_byte <= pair[1].start_byte,
"pre-order start_byte must be non-decreasing: {} then {}",
pair[0].start_byte,
pair[1].start_byte
);
}
}

#[test]
fn start_line_advances_for_nodes_on_later_lines() {
let source = "fn a() {}\nfn b() {}\nfn c() {}\n";
let result = parse_and_linearize(source, Language::Rust);
// At least one node must be recorded on each of lines 1, 2, and 3 —
// proves start_line is actually populated from the real source position,
// not left at the zero default.
for expected_line in [1u32, 2, 3] {
assert!(
result.nodes.iter().any(|n| n.start_line == expected_line),
"expected at least one node on line {expected_line}; lines seen: {:?}",
result
.nodes
.iter()
.map(|n| n.start_line)
.collect::<Vec<_>>()
);
}
}

#[test]
fn linearize_result_default_is_empty() {
let r = LinearizeResult::default();
Expand Down
10 changes: 8 additions & 2 deletions crates/rskim-search/src/ast_index/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ pub use ast_cache::{
};
pub use extract::{
AstBigramEntry, AstNgramSet, AstTrigramEntry, extract_ast_ngrams,
extract_ast_ngrams_with_metrics, extract_ast_ngrams_with_weights,
extract_ast_ngrams_with_lines, extract_ast_ngrams_with_metrics,
extract_ast_ngrams_with_weights, synthetic_key_present,
};
pub use linearize::{LinearNode, LinearizeResult, linearize_source};
pub use ngram::{
Expand All @@ -70,4 +71,9 @@ pub use query::{
AST_BM25_B, AST_BM25_K1, AstPostingSource, AstQuery, AstQueryEngine, parse_ast_query,
};
pub use store::{AstFileMetaEntry, AstIndexBuilder, AstIndexReader, AstPosting};
pub use structural::StructuralMetrics;
// AD-394-3: `is_synthetic_id` re-exported as the single public source of truth
// for "is this NodeKindId a synthetic marker ID" — avoids callers (both within
// this crate and in `rskim`'s CLI tests) re-deriving the `>= BUCKET_LABEL_BASE`
// threshold from a duplicated magic number. `structural` itself stays
// `pub(crate)`; this is a deliberate, narrow re-export of one predicate.
pub use structural::{StructuralMetrics, is_synthetic_id};
21 changes: 21 additions & 0 deletions crates/rskim-search/src/ast_index/structural.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,27 @@ pub fn bucket_label(edge_index: usize) -> NodeKindId {
BUCKET_LABEL_BASE + edge_index as NodeKindId
}

/// AD-394-3: `true` iff `id` is a synthetic marker ID — either a reserved
/// synthetic PARENT id (`EMPTY_BODY`/`DEEP_NODE`/`LARGE_BODY`/`MANY_PARAMS`,
/// 65000-65003) or a bucket-label CHILD id (`>= BUCKET_LABEL_BASE` = 64900).
///
/// Synthetic parent IDs and bucket-label child IDs are all `>= BUCKET_LABEL_BASE`
/// (64900), while every real vocabulary ID satisfies `id < vocab_len()` (1740).
/// `BUCKET_LABEL_BASE` cleanly separates the two ID spaces — the invariant
/// `vocab_len() <= BUCKET_LABEL_BASE` is asserted by a unit test below.
///
/// This is the single source of truth for the "is this id synthetic?"
/// predicate, used by `compound::reparse`'s verify gate and `recover_line` to
/// route synthetic-marker AST patterns through extraction-reuse instead of the
/// real-CST ancestor walk (`vocab_lookup` never yields a synthetic ID, so the
/// strict walk is structurally unsatisfiable for them). Previously duplicated
/// ad hoc as an inline `>= BUCKET_LABEL_BASE` check in `extract_tests.rs`.
#[inline]
#[must_use]
pub fn is_synthetic_id(id: NodeKindId) -> bool {
id >= BUCKET_LABEL_BASE
}

// ============================================================================
// Bucket edge tables
// ============================================================================
Expand Down
Loading
Loading