From be1161cc0f398a39bcc725db488e24863ec6ba7a Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 24 Jul 2026 18:13:39 +0200 Subject: [PATCH 1/2] Emit bounded AST lowering metrics (#308) Record one latency sample and one categorized outcome for every lowering attempt, with parser recovery tracked orthogonally. --- Cargo.lock | 125 +++++++++++++++ Cargo.toml | 2 + crates/whitaker_clones_core/Cargo.toml | 2 + .../whitaker_clones_core/src/ast/lowering.rs | 40 +++-- .../src/ast/lowering_tests.rs | 124 +++++++++++++++ .../whitaker_clones_core/src/ast/metrics.rs | 146 ++++++++++++++++++ crates/whitaker_clones_core/src/ast/mod.rs | 10 +- docs/whitaker-clone-detector-design.md | 14 ++ 8 files changed, 451 insertions(+), 12 deletions(-) create mode 100644 crates/whitaker_clones_core/src/ast/metrics.rs diff --git a/Cargo.lock b/Cargo.lock index e4a0f5a0..971126f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -843,6 +843,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + [[package]] name = "env_filter" version = "2.0.0" @@ -1800,6 +1806,37 @@ dependencies = [ "autocfg", ] +[[package]] +name = "metrics" +version = "0.24.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2" +dependencies = [ + "portable-atomic", + "rapidhash", +] + +[[package]] +name = "metrics-util" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96f8722f8562635f92f8ed992f26df0532266eb03d5202607c20c0d7e9745e13" +dependencies = [ + "aho-corasick", + "crossbeam-epoch", + "crossbeam-utils", + "hashbrown 0.16.1", + "indexmap", + "metrics", + "ordered-float", + "quanta", + "radix_trie", + "rand", + "rand_xoshiro", + "rapidhash", + "sketches-ddsketch", +] + [[package]] name = "mime" version = "0.3.17" @@ -1910,6 +1947,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c8b7b69b0eafaa88ec8dc9fe7c3860af0a147517e5207cfbd0ecd21cd7cde18" +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + [[package]] name = "no_expect_outside_tests" version = "0.2.7" @@ -2040,6 +2086,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -2262,6 +2317,21 @@ dependencies = [ "unarray", ] +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -2355,6 +2425,16 @@ dependencies = [ "triomphe", ] +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + [[package]] name = "rand" version = "0.9.5" @@ -2393,6 +2473,33 @@ dependencies = [ "rand_core", ] +[[package]] +name = "rand_xoshiro" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rapidhash" +version = "4.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2940,6 +3047,12 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "sketches-ddsketch" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" + [[package]] name = "slab" version = "0.4.12" @@ -3649,6 +3762,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "1.0.8" @@ -3739,6 +3862,8 @@ dependencies = [ "camino", "cap-std 4.0.2", "insta", + "metrics", + "metrics-util", "proptest", "ra_ap_syntax", "rstest", diff --git a/Cargo.toml b/Cargo.toml index b98329d1..f42f4833 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,8 @@ serde = { version = "1.0.228", features = ["derive"] } flate2 = "1.1" serde_json = "1.0.149" toml = "1.1.2" +metrics = "0.24.2" +metrics-util = "0.20.1" tracing = { version = "0.1.44", default-features = false, features = ["attributes", "std"] } thiserror = "2" trybuild = "1.0.116" diff --git a/crates/whitaker_clones_core/Cargo.toml b/crates/whitaker_clones_core/Cargo.toml index d9c317d8..9a5e238a 100644 --- a/crates/whitaker_clones_core/Cargo.toml +++ b/crates/whitaker_clones_core/Cargo.toml @@ -17,6 +17,7 @@ thiserror = { workspace = true } rustc_lexer = { workspace = true } ra_ap_syntax = { workspace = true, optional = true } sha2 = { workspace = true } +metrics = { workspace = true } tracing = { workspace = true } whitaker_sarif = { workspace = true } @@ -24,6 +25,7 @@ whitaker_sarif = { workspace = true } camino = { workspace = true } cap-std = { workspace = true } insta = { workspace = true } +metrics-util = { workspace = true } proptest = { workspace = true } rstest = { workspace = true } rstest-bdd = { workspace = true } diff --git a/crates/whitaker_clones_core/src/ast/lowering.rs b/crates/whitaker_clones_core/src/ast/lowering.rs index 96f0b38f..506f8d66 100644 --- a/crates/whitaker_clones_core/src/ast/lowering.rs +++ b/crates/whitaker_clones_core/src/ast/lowering.rs @@ -2,6 +2,7 @@ use std::cell::Cell; use std::ops::Range; +use std::time::Instant; use ra_ap_syntax::{ AstNode, Edition, NodeOrToken, SourceFile, SyntaxKind, SyntaxNode, SyntaxToken, TextRange, @@ -9,6 +10,7 @@ use ra_ap_syntax::{ }; use tracing::{debug, error, warn}; +use super::metrics::record_lower_span_metrics; use super::{AstError, AstResult, ByteSpan, KindId, LeafClass, NormalizedNode, NormalizedTree}; pub use crate::hashing::PARSER_SCHEMA_VERSION; @@ -44,8 +46,9 @@ fn trace_ast_error( /// text. This defence-in-depth check is not redundant and must remain in place /// even though it resembles double validation. /// -/// Latency metrics and feature-vector emission metrics are deferred to 7.3.2, -/// where scoring and SARIF emission consume those observations. +/// Latency and categorized outcome metrics emit at this lowering boundary. +/// Feature-vector emission metrics remain reserved for the 7.3.2 scoring and +/// SARIF Run 1 consumption boundary. /// /// # Examples /// @@ -60,16 +63,27 @@ fn trace_ast_error( /// ``` #[tracing::instrument(skip(file_text), fields(start = span.start(), end = span.end()))] pub fn lower_span(file_text: &str, span: ByteSpan) -> AstResult { - let span = ByteSpan::new(file_text, span.start(), span.end()).map_err(|error| { + let started_at = Instant::now(); + let (result, recovered) = lower_span_inner(file_text, span); + record_lower_span_metrics(&result, started_at.elapsed(), recovered); + result +} + +fn lower_span_inner(file_text: &str, span: ByteSpan) -> (AstResult, bool) { + let span = match ByteSpan::new(file_text, span.start(), span.end()).map_err(|error| { trace_ast_error( error, "AST span lies outside the supplied source text", "AST span validation failed", ) - })?; + }) { + Ok(span) => span, + Err(error) => return (Err(error), false), + }; let parse = SourceFile::parse(file_text, Edition::CURRENT); let parse_errors = parse.errors(); - if !parse_errors.is_empty() { + let recovered = !parse_errors.is_empty(); + if recovered { // This is the designated logging boundary for parser recovery in this // adapter; the lowered AST domain remains parser-agnostic. warn!( @@ -81,13 +95,16 @@ pub fn lower_span(file_text: &str, span: ByteSpan) -> AstResult } let root = parse.tree().syntax().clone(); let target_range = text_range(span); - let selected = select_covering_node(&root, span).map_err(|error| { + let selected = match select_covering_node(&root, span).map_err(|error| { trace_ast_error( error, "no AST syntax node covers the requested span", "AST covering-node selection failed", ) - })?; + }) { + Ok(selected) => selected, + Err(error) => return (Err(error), recovered), + }; debug!( kind = ?selected.kind(), span_width = u32::from(selected.text_range().len()), @@ -97,7 +114,7 @@ pub fn lower_span(file_text: &str, span: ByteSpan) -> AstResult // Lowering doubles as parser-error detection: a single descent both builds // the normalized subtree and rejects any `ERROR` node or token, so the // selected span is never walked twice. - let lowered = LoweringLimits::new(span) + let lowered = match LoweringLimits::new(span) .lower(&selected, 0) .map_err(|error| { if matches!(error, AstError::UnparsableSpan { .. }) { @@ -108,10 +125,13 @@ pub fn lower_span(file_text: &str, span: ByteSpan) -> AstResult ); } error - })?; + }) { + Ok(lowered) => lowered, + Err(error) => return (Err(error), recovered), + }; debug_assert!(selected.text_range().contains_range(target_range)); - Ok(NormalizedTree::new(lowered, span)) + (Ok(NormalizedTree::new(lowered, span)), recovered) } fn validate_covering_node_budget(span: ByteSpan, depth: usize, node_count: usize) -> AstResult<()> { diff --git a/crates/whitaker_clones_core/src/ast/lowering_tests.rs b/crates/whitaker_clones_core/src/ast/lowering_tests.rs index 28f6878a..66e7b293 100644 --- a/crates/whitaker_clones_core/src/ast/lowering_tests.rs +++ b/crates/whitaker_clones_core/src/ast/lowering_tests.rs @@ -1,6 +1,7 @@ //! Adapter-scoped tests for Rust syntax lowering. use insta::assert_json_snapshot; +use metrics_util::debugging::{DebugValue, DebuggingRecorder, Snapshot}; use rstest::rstest; use serde_json::json; @@ -10,6 +11,11 @@ use super::{ LoweringLimits, MAX_AST_DEPTH, MAX_AST_NODES, kind_id, leaf_class, validate_covering_node_budget, }; +use crate::ast::metrics::{ + LOWER_SPAN_DURATION_SECONDS, LOWER_SPAN_PARSER_RECOVERY_TOTAL, LOWER_SPAN_TOTAL, + OUTCOME_DEPTH_BUDGET_EXHAUSTED, OUTCOME_INVALID_SPAN, OUTCOME_NODE_BUDGET_EXHAUSTED, + OUTCOME_SUCCESS, OUTCOME_UNPARSABLE_SPAN, +}; use crate::{ AstError, ByteSpan, Production, ast::{KindId, LeafClass, NormalizedNode, NormalizedTree, PARSER_SCHEMA_VERSION}, @@ -202,6 +208,124 @@ fn error_subtree_is_rejected() -> Result<(), AstError> { Ok(()) } +enum MetricsScenario { + Success, + InvalidSpan, + UnparsableSpan, + NodeBudgetExhausted, + DepthBudgetExhausted, +} + +#[rstest] +#[case::success(MetricsScenario::Success, OUTCOME_SUCCESS, false)] +#[case::invalid_span(MetricsScenario::InvalidSpan, OUTCOME_INVALID_SPAN, false)] +#[case::unparsable_span(MetricsScenario::UnparsableSpan, OUTCOME_UNPARSABLE_SPAN, true)] +#[case::node_budget_exhausted( + MetricsScenario::NodeBudgetExhausted, + OUTCOME_NODE_BUDGET_EXHAUSTED, + false +)] +#[case::depth_budget_exhausted( + MetricsScenario::DepthBudgetExhausted, + OUTCOME_DEPTH_BUDGET_EXHAUSTED, + false +)] +fn lower_span_records_bounded_outcome_and_latency( + #[case] scenario: MetricsScenario, + #[case] expected_outcome: &'static str, + #[case] recovered: bool, +) -> Result<(), AstError> { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + metrics::with_local_recorder(&recorder, || run_metrics_scenario(scenario))?; + + assert_lower_span_metrics(snapshotter.snapshot(), expected_outcome, recovered); + Ok(()) +} + +#[rstest] +fn parser_recovery_is_recorded_alongside_success() -> Result<(), AstError> { + let source = "fn valid() {} trailing"; + let span = ByteSpan::new(source, 0, "fn valid() {}".len() as u32)?; + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let result = metrics::with_local_recorder(&recorder, || lower_span(source, span)); + + assert!(result.is_ok(), "the selected valid function should lower"); + assert_lower_span_metrics(snapshotter.snapshot(), OUTCOME_SUCCESS, true); + Ok(()) +} + +fn run_metrics_scenario(scenario: MetricsScenario) -> Result<(), AstError> { + let (source, span) = metrics_scenario_input(scenario)?; + let _result = lower_span(&source, span); + Ok(()) +} + +fn metrics_scenario_input(scenario: MetricsScenario) -> Result<(String, ByteSpan), AstError> { + match scenario { + MetricsScenario::Success => source_and_whole_span("fn f() {}".to_owned()), + MetricsScenario::InvalidSpan => { + let span = ByteSpan::new("longer", 0, 6)?; + Ok(("short".to_owned(), span)) + } + MetricsScenario::UnparsableSpan => source_and_whole_span("@error@".to_owned()), + MetricsScenario::NodeBudgetExhausted => { + let statements = (0..=MAX_AST_NODES) + .map(|index| format!("let value_{index} = {index};")) + .collect::>() + .join(" "); + source_and_whole_span(format!("fn generated() {{ {statements} }}")) + } + MetricsScenario::DepthBudgetExhausted => { + let source = format!( + "fn deeply_nested() {{ {}0;{} }}", + "{".repeat(MAX_AST_DEPTH + 1), + "}".repeat(MAX_AST_DEPTH + 1) + ); + source_and_whole_span(source) + } + } +} + +fn source_and_whole_span(source: String) -> Result<(String, ByteSpan), AstError> { + let span = ByteSpan::new(&source, 0, source.len() as u32)?; + Ok((source, span)) +} + +fn assert_lower_span_metrics(snapshot: Snapshot, expected_outcome: &str, recovered: bool) { + let snapshot = snapshot.into_vec(); + let has_outcome = snapshot.iter().any(|(key, _, _, value)| { + key.key().name() == LOWER_SPAN_TOTAL + && key + .key() + .labels() + .any(|label| label.key() == "outcome" && label.value() == expected_outcome) + && matches!(value, DebugValue::Counter(1)) + }); + let has_latency = snapshot.iter().any(|(key, _, _, value)| { + key.key().name() == LOWER_SPAN_DURATION_SECONDS + && matches!(value, DebugValue::Histogram(samples) if samples.len() == 1) + }); + let has_recovery = snapshot.iter().any(|(key, _, _, value)| { + key.key().name() == LOWER_SPAN_PARSER_RECOVERY_TOTAL + && matches!(value, DebugValue::Counter(1)) + }); + + assert!( + has_outcome, + "expected outcome {expected_outcome}; got {snapshot:#?}" + ); + assert!( + has_latency, + "expected one latency sample; got {snapshot:#?}" + ); + assert_eq!( + has_recovery, recovered, + "unexpected recovery metric: {snapshot:#?}" + ); +} + fn lower_span_for(source: &str, needle: &str) -> Result { let start = source .find(needle) diff --git a/crates/whitaker_clones_core/src/ast/metrics.rs b/crates/whitaker_clones_core/src/ast/metrics.rs new file mode 100644 index 00000000..7c9ad306 --- /dev/null +++ b/crates/whitaker_clones_core/src/ast/metrics.rs @@ -0,0 +1,146 @@ +//! Bounded operational metrics for AST lowering. + +use std::{sync::Once, time::Duration}; + +use super::{AstError, AstResult, NormalizedTree}; + +pub(crate) const LOWER_SPAN_DURATION_SECONDS: &str = + "whitaker_clones.ast.lower_span.duration_seconds"; +pub(crate) const LOWER_SPAN_TOTAL: &str = "whitaker_clones.ast.lower_span.total"; +pub(crate) const LOWER_SPAN_PARSER_RECOVERY_TOTAL: &str = + "whitaker_clones.ast.lower_span.parser_recovery.total"; + +pub(crate) const OUTCOME_SUCCESS: &str = "success"; +pub(crate) const OUTCOME_INVALID_SPAN: &str = "invalid_span"; +pub(crate) const OUTCOME_UNPARSABLE_SPAN: &str = "unparsable_span"; +pub(crate) const OUTCOME_NODE_BUDGET_EXHAUSTED: &str = "node_budget_exhausted"; +pub(crate) const OUTCOME_DEPTH_BUDGET_EXHAUSTED: &str = "depth_budget_exhausted"; +pub(crate) const OUTCOME_PARSER_UNAVAILABLE: &str = "parser_unavailable"; + +static DESCRIBE_METRICS: Once = Once::new(); + +pub(crate) fn outcome_label(result: &AstResult) -> &'static str { + match result { + Ok(_) => OUTCOME_SUCCESS, + Err(AstError::TreeTooLarge { .. }) => OUTCOME_NODE_BUDGET_EXHAUSTED, + Err(AstError::TreeTooDeep { .. }) => OUTCOME_DEPTH_BUDGET_EXHAUSTED, + Err(AstError::UnparsableSpan { .. }) => OUTCOME_UNPARSABLE_SPAN, + Err( + AstError::InvalidSpan { .. } + | AstError::EmptySpan { .. } + | AstError::NonCharBoundary { .. } + | AstError::OffsetTooLarge(_) + | AstError::SpanOutOfBounds { .. }, + ) => OUTCOME_INVALID_SPAN, + Err(AstError::ParserUnavailable) => OUTCOME_PARSER_UNAVAILABLE, + } +} + +pub(crate) fn record_lower_span_metrics( + result: &AstResult, + duration: Duration, + recovered: bool, +) { + describe_metrics(); + metrics::histogram!(LOWER_SPAN_DURATION_SECONDS).record(duration.as_secs_f64()); + metrics::counter!(LOWER_SPAN_TOTAL, "outcome" => outcome_label(result)).increment(1); + if recovered { + metrics::counter!(LOWER_SPAN_PARSER_RECOVERY_TOTAL).increment(1); + } +} + +fn describe_metrics() { + DESCRIBE_METRICS.call_once(|| { + metrics::describe_histogram!( + LOWER_SPAN_DURATION_SECONDS, + metrics::Unit::Seconds, + "Elapsed time spent validating, parsing, and lowering an AST span" + ); + metrics::describe_counter!( + LOWER_SPAN_TOTAL, + "AST span lowering attempts categorized by bounded outcome" + ); + metrics::describe_counter!( + LOWER_SPAN_PARSER_RECOVERY_TOTAL, + "AST span lowering attempts whose source required parser recovery" + ); + }); +} + +#[cfg(test)] +mod tests { + #[cfg(not(feature = "parser"))] + use metrics_util::debugging::{DebugValue, DebuggingRecorder}; + use rstest::rstest; + + use super::*; + use crate::ast::{ByteSpan, KindId, NormalizedNode}; + + fn successful_result() -> AstResult { + let span = ByteSpan::new("x", 0, 1)?; + Ok(NormalizedTree::new( + NormalizedNode::new(KindId::new(0), None, Vec::new()), + span, + )) + } + + #[rstest] + #[case::success(successful_result(), OUTCOME_SUCCESS)] + #[case::invalid_span( + Err(AstError::InvalidSpan { start: 2, end: 1 }), + OUTCOME_INVALID_SPAN + )] + #[case::empty_span(Err(AstError::EmptySpan { offset: 1 }), OUTCOME_INVALID_SPAN)] + #[case::non_char_boundary( + Err(AstError::NonCharBoundary { offset: 1 }), + OUTCOME_INVALID_SPAN + )] + #[case::offset_too_large(Err(AstError::OffsetTooLarge(usize::MAX)), OUTCOME_INVALID_SPAN)] + #[case::span_out_of_bounds( + Err(AstError::SpanOutOfBounds { start: 0, end: 2, len: 1 }), + OUTCOME_INVALID_SPAN + )] + #[case::parser_unavailable(Err(AstError::ParserUnavailable), OUTCOME_PARSER_UNAVAILABLE)] + #[case::node_budget( + Err(AstError::TreeTooLarge { limit: 10 }), + OUTCOME_NODE_BUDGET_EXHAUSTED + )] + #[case::depth_budget( + Err(AstError::TreeTooDeep { limit: 10 }), + OUTCOME_DEPTH_BUDGET_EXHAUSTED + )] + #[case::unparsable_span( + Err(AstError::UnparsableSpan { start: 0, end: 1 }), + OUTCOME_UNPARSABLE_SPAN + )] + fn ast_results_have_bounded_outcomes( + #[case] result: AstResult, + #[case] expected: &'static str, + ) { + assert_eq!(outcome_label(&result), expected); + } + + #[cfg(not(feature = "parser"))] + #[rstest] + fn parser_unavailable_stub_records_outcome_and_latency() -> Result<(), AstError> { + let span = ByteSpan::new("x", 0, 1)?; + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let result = metrics::with_local_recorder(&recorder, || crate::lower_span("x", span)); + let snapshot = snapshotter.snapshot().into_vec(); + + assert_eq!(result, Err(AstError::ParserUnavailable)); + assert!(snapshot.iter().any(|(key, _, _, value)| { + key.key().name() == LOWER_SPAN_TOTAL + && key.key().labels().any(|label| { + label.key() == "outcome" && label.value() == OUTCOME_PARSER_UNAVAILABLE + }) + && matches!(value, DebugValue::Counter(1)) + })); + assert!(snapshot.iter().any(|(key, _, _, value)| { + key.key().name() == LOWER_SPAN_DURATION_SECONDS + && matches!(value, DebugValue::Histogram(samples) if samples.len() == 1) + })); + Ok(()) + } +} diff --git a/crates/whitaker_clones_core/src/ast/mod.rs b/crates/whitaker_clones_core/src/ast/mod.rs index 1458a54b..89bf0ec3 100644 --- a/crates/whitaker_clones_core/src/ast/mod.rs +++ b/crates/whitaker_clones_core/src/ast/mod.rs @@ -13,11 +13,14 @@ mod features; mod hash; #[cfg(kani)] mod kani; +mod metrics; #[cfg(not(feature = "parser"))] mod lowering { //! No-parser lowering stub for verification builds. - use super::{AstError, ByteSpan, NormalizedTree}; + use std::time::Instant; + + use super::{AstError, ByteSpan, NormalizedTree, metrics::record_lower_span_metrics}; /// Parser schema seed shared with AST hashing. pub const PARSER_SCHEMA_VERSION: &str = crate::hashing::PARSER_SCHEMA_VERSION; @@ -25,7 +28,10 @@ mod lowering { /// Reports that parser-backed lowering is unavailable without the /// `parser` feature. pub fn lower_span(_file_text: &str, _span: ByteSpan) -> Result { - Err(AstError::ParserUnavailable) + let started_at = Instant::now(); + let result = Err(AstError::ParserUnavailable); + record_lower_span_metrics(&result, started_at.elapsed(), false); + result } } #[cfg(feature = "parser")] diff --git a/docs/whitaker-clone-detector-design.md b/docs/whitaker-clone-detector-design.md index 0ffc2afc..3f56473f 100644 --- a/docs/whitaker-clone-detector-design.md +++ b/docs/whitaker-clone-detector-design.md @@ -273,6 +273,20 @@ pub fn describe_lowering_failure(file_text: &str, start: usize, end: usize) -> S - **Canonical subtree hash:** Merkle-style hash where leaves are normalized (``, ``), and internal nodes include kind plus arity. +### AST operational metrics + +The parser-agnostic `ast::metrics` module owns the stable metric names, the +bounded outcome vocabulary, and the exhaustive `AstError` categorization. +`lower_span` is the only permitted emitter for lowering latency, outcomes and +parser recovery, including the no-parser fallback. Callers must compose with +that boundary rather than emitting duplicate lowering observations. + +The outcome label is limited to `success`, `invalid_span`, `unparsable_span`, +`node_budget_exhausted`, `depth_budget_exhausted` and `parser_unavailable`. +Metrics must not label individual nodes, spans, paths or raw source text. +Feature-vector emission remains owned by the 7.3.2 scoring and SARIF Run 1 +consumer so a vector is counted only when that boundary consumes it. + ### Scoring and acceptance (Type-3) - Compute cosine similarity between histograms. From 02aaf844dc8474ffbb0aec254443998712828b30 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 24 Jul 2026 18:20:37 +0200 Subject: [PATCH 2/2] Split AST metrics tests by concern (#308) Keep adapter metrics scenarios separate from syntax-lowering tests so both modules remain below the repository file-size ceiling. --- .../whitaker_clones_core/src/ast/lowering.rs | 3 + .../src/ast/lowering_metrics_tests.rs | 130 ++++++++++++++++++ .../src/ast/lowering_tests.rs | 124 ----------------- 3 files changed, 133 insertions(+), 124 deletions(-) create mode 100644 crates/whitaker_clones_core/src/ast/lowering_metrics_tests.rs diff --git a/crates/whitaker_clones_core/src/ast/lowering.rs b/crates/whitaker_clones_core/src/ast/lowering.rs index 506f8d66..187aff4f 100644 --- a/crates/whitaker_clones_core/src/ast/lowering.rs +++ b/crates/whitaker_clones_core/src/ast/lowering.rs @@ -363,6 +363,9 @@ fn range_to_u32(range: TextRange) -> Range { u32::from(range.start())..u32::from(range.end()) } +#[cfg(test)] +#[path = "lowering_metrics_tests.rs"] +mod metrics_tests; #[cfg(test)] #[path = "lowering_tests.rs"] mod tests; diff --git a/crates/whitaker_clones_core/src/ast/lowering_metrics_tests.rs b/crates/whitaker_clones_core/src/ast/lowering_metrics_tests.rs new file mode 100644 index 00000000..cefe449d --- /dev/null +++ b/crates/whitaker_clones_core/src/ast/lowering_metrics_tests.rs @@ -0,0 +1,130 @@ +//! Operational-metrics tests for Rust syntax lowering. + +use metrics_util::debugging::{DebugValue, DebuggingRecorder, Snapshot}; +use rstest::rstest; + +use super::{MAX_AST_DEPTH, MAX_AST_NODES, lower_span}; +use crate::ast::metrics::{ + LOWER_SPAN_DURATION_SECONDS, LOWER_SPAN_PARSER_RECOVERY_TOTAL, LOWER_SPAN_TOTAL, + OUTCOME_DEPTH_BUDGET_EXHAUSTED, OUTCOME_INVALID_SPAN, OUTCOME_NODE_BUDGET_EXHAUSTED, + OUTCOME_SUCCESS, OUTCOME_UNPARSABLE_SPAN, +}; +use crate::{AstError, ByteSpan}; + +enum MetricsScenario { + Success, + InvalidSpan, + UnparsableSpan, + NodeBudgetExhausted, + DepthBudgetExhausted, +} + +#[rstest] +#[case::success(MetricsScenario::Success, OUTCOME_SUCCESS, false)] +#[case::invalid_span(MetricsScenario::InvalidSpan, OUTCOME_INVALID_SPAN, false)] +#[case::unparsable_span(MetricsScenario::UnparsableSpan, OUTCOME_UNPARSABLE_SPAN, true)] +#[case::node_budget_exhausted( + MetricsScenario::NodeBudgetExhausted, + OUTCOME_NODE_BUDGET_EXHAUSTED, + false +)] +#[case::depth_budget_exhausted( + MetricsScenario::DepthBudgetExhausted, + OUTCOME_DEPTH_BUDGET_EXHAUSTED, + false +)] +fn lower_span_records_bounded_outcome_and_latency( + #[case] scenario: MetricsScenario, + #[case] expected_outcome: &'static str, + #[case] recovered: bool, +) -> Result<(), AstError> { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + metrics::with_local_recorder(&recorder, || run_metrics_scenario(scenario))?; + + assert_lower_span_metrics(snapshotter.snapshot(), expected_outcome, recovered); + Ok(()) +} + +#[rstest] +fn parser_recovery_is_recorded_alongside_success() -> Result<(), AstError> { + let source = "fn valid() {} trailing"; + let span = ByteSpan::new(source, 0, "fn valid() {}".len() as u32)?; + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let result = metrics::with_local_recorder(&recorder, || lower_span(source, span)); + + assert!(result.is_ok(), "the selected valid function should lower"); + assert_lower_span_metrics(snapshotter.snapshot(), OUTCOME_SUCCESS, true); + Ok(()) +} + +fn run_metrics_scenario(scenario: MetricsScenario) -> Result<(), AstError> { + let (source, span) = metrics_scenario_input(scenario)?; + let _result = lower_span(&source, span); + Ok(()) +} + +fn metrics_scenario_input(scenario: MetricsScenario) -> Result<(String, ByteSpan), AstError> { + match scenario { + MetricsScenario::Success => source_and_whole_span("fn f() {}".to_owned()), + MetricsScenario::InvalidSpan => { + let span = ByteSpan::new("longer", 0, 6)?; + Ok(("short".to_owned(), span)) + } + MetricsScenario::UnparsableSpan => source_and_whole_span("@error@".to_owned()), + MetricsScenario::NodeBudgetExhausted => { + let statements = (0..=MAX_AST_NODES) + .map(|index| format!("let value_{index} = {index};")) + .collect::>() + .join(" "); + source_and_whole_span(format!("fn generated() {{ {statements} }}")) + } + MetricsScenario::DepthBudgetExhausted => { + let source = format!( + "fn deeply_nested() {{ {}0;{} }}", + "{".repeat(MAX_AST_DEPTH + 1), + "}".repeat(MAX_AST_DEPTH + 1) + ); + source_and_whole_span(source) + } + } +} + +fn source_and_whole_span(source: String) -> Result<(String, ByteSpan), AstError> { + let span = ByteSpan::new(&source, 0, source.len() as u32)?; + Ok((source, span)) +} + +fn assert_lower_span_metrics(snapshot: Snapshot, expected_outcome: &str, recovered: bool) { + let snapshot = snapshot.into_vec(); + let has_outcome = snapshot.iter().any(|(key, _, _, value)| { + key.key().name() == LOWER_SPAN_TOTAL + && key + .key() + .labels() + .any(|label| label.key() == "outcome" && label.value() == expected_outcome) + && matches!(value, DebugValue::Counter(1)) + }); + let has_latency = snapshot.iter().any(|(key, _, _, value)| { + key.key().name() == LOWER_SPAN_DURATION_SECONDS + && matches!(value, DebugValue::Histogram(samples) if samples.len() == 1) + }); + let has_recovery = snapshot.iter().any(|(key, _, _, value)| { + key.key().name() == LOWER_SPAN_PARSER_RECOVERY_TOTAL + && matches!(value, DebugValue::Counter(1)) + }); + + assert!( + has_outcome, + "expected outcome {expected_outcome}; got {snapshot:#?}" + ); + assert!( + has_latency, + "expected one latency sample; got {snapshot:#?}" + ); + assert_eq!( + has_recovery, recovered, + "unexpected recovery metric: {snapshot:#?}" + ); +} diff --git a/crates/whitaker_clones_core/src/ast/lowering_tests.rs b/crates/whitaker_clones_core/src/ast/lowering_tests.rs index 66e7b293..28f6878a 100644 --- a/crates/whitaker_clones_core/src/ast/lowering_tests.rs +++ b/crates/whitaker_clones_core/src/ast/lowering_tests.rs @@ -1,7 +1,6 @@ //! Adapter-scoped tests for Rust syntax lowering. use insta::assert_json_snapshot; -use metrics_util::debugging::{DebugValue, DebuggingRecorder, Snapshot}; use rstest::rstest; use serde_json::json; @@ -11,11 +10,6 @@ use super::{ LoweringLimits, MAX_AST_DEPTH, MAX_AST_NODES, kind_id, leaf_class, validate_covering_node_budget, }; -use crate::ast::metrics::{ - LOWER_SPAN_DURATION_SECONDS, LOWER_SPAN_PARSER_RECOVERY_TOTAL, LOWER_SPAN_TOTAL, - OUTCOME_DEPTH_BUDGET_EXHAUSTED, OUTCOME_INVALID_SPAN, OUTCOME_NODE_BUDGET_EXHAUSTED, - OUTCOME_SUCCESS, OUTCOME_UNPARSABLE_SPAN, -}; use crate::{ AstError, ByteSpan, Production, ast::{KindId, LeafClass, NormalizedNode, NormalizedTree, PARSER_SCHEMA_VERSION}, @@ -208,124 +202,6 @@ fn error_subtree_is_rejected() -> Result<(), AstError> { Ok(()) } -enum MetricsScenario { - Success, - InvalidSpan, - UnparsableSpan, - NodeBudgetExhausted, - DepthBudgetExhausted, -} - -#[rstest] -#[case::success(MetricsScenario::Success, OUTCOME_SUCCESS, false)] -#[case::invalid_span(MetricsScenario::InvalidSpan, OUTCOME_INVALID_SPAN, false)] -#[case::unparsable_span(MetricsScenario::UnparsableSpan, OUTCOME_UNPARSABLE_SPAN, true)] -#[case::node_budget_exhausted( - MetricsScenario::NodeBudgetExhausted, - OUTCOME_NODE_BUDGET_EXHAUSTED, - false -)] -#[case::depth_budget_exhausted( - MetricsScenario::DepthBudgetExhausted, - OUTCOME_DEPTH_BUDGET_EXHAUSTED, - false -)] -fn lower_span_records_bounded_outcome_and_latency( - #[case] scenario: MetricsScenario, - #[case] expected_outcome: &'static str, - #[case] recovered: bool, -) -> Result<(), AstError> { - let recorder = DebuggingRecorder::new(); - let snapshotter = recorder.snapshotter(); - metrics::with_local_recorder(&recorder, || run_metrics_scenario(scenario))?; - - assert_lower_span_metrics(snapshotter.snapshot(), expected_outcome, recovered); - Ok(()) -} - -#[rstest] -fn parser_recovery_is_recorded_alongside_success() -> Result<(), AstError> { - let source = "fn valid() {} trailing"; - let span = ByteSpan::new(source, 0, "fn valid() {}".len() as u32)?; - let recorder = DebuggingRecorder::new(); - let snapshotter = recorder.snapshotter(); - let result = metrics::with_local_recorder(&recorder, || lower_span(source, span)); - - assert!(result.is_ok(), "the selected valid function should lower"); - assert_lower_span_metrics(snapshotter.snapshot(), OUTCOME_SUCCESS, true); - Ok(()) -} - -fn run_metrics_scenario(scenario: MetricsScenario) -> Result<(), AstError> { - let (source, span) = metrics_scenario_input(scenario)?; - let _result = lower_span(&source, span); - Ok(()) -} - -fn metrics_scenario_input(scenario: MetricsScenario) -> Result<(String, ByteSpan), AstError> { - match scenario { - MetricsScenario::Success => source_and_whole_span("fn f() {}".to_owned()), - MetricsScenario::InvalidSpan => { - let span = ByteSpan::new("longer", 0, 6)?; - Ok(("short".to_owned(), span)) - } - MetricsScenario::UnparsableSpan => source_and_whole_span("@error@".to_owned()), - MetricsScenario::NodeBudgetExhausted => { - let statements = (0..=MAX_AST_NODES) - .map(|index| format!("let value_{index} = {index};")) - .collect::>() - .join(" "); - source_and_whole_span(format!("fn generated() {{ {statements} }}")) - } - MetricsScenario::DepthBudgetExhausted => { - let source = format!( - "fn deeply_nested() {{ {}0;{} }}", - "{".repeat(MAX_AST_DEPTH + 1), - "}".repeat(MAX_AST_DEPTH + 1) - ); - source_and_whole_span(source) - } - } -} - -fn source_and_whole_span(source: String) -> Result<(String, ByteSpan), AstError> { - let span = ByteSpan::new(&source, 0, source.len() as u32)?; - Ok((source, span)) -} - -fn assert_lower_span_metrics(snapshot: Snapshot, expected_outcome: &str, recovered: bool) { - let snapshot = snapshot.into_vec(); - let has_outcome = snapshot.iter().any(|(key, _, _, value)| { - key.key().name() == LOWER_SPAN_TOTAL - && key - .key() - .labels() - .any(|label| label.key() == "outcome" && label.value() == expected_outcome) - && matches!(value, DebugValue::Counter(1)) - }); - let has_latency = snapshot.iter().any(|(key, _, _, value)| { - key.key().name() == LOWER_SPAN_DURATION_SECONDS - && matches!(value, DebugValue::Histogram(samples) if samples.len() == 1) - }); - let has_recovery = snapshot.iter().any(|(key, _, _, value)| { - key.key().name() == LOWER_SPAN_PARSER_RECOVERY_TOTAL - && matches!(value, DebugValue::Counter(1)) - }); - - assert!( - has_outcome, - "expected outcome {expected_outcome}; got {snapshot:#?}" - ); - assert!( - has_latency, - "expected one latency sample; got {snapshot:#?}" - ); - assert_eq!( - has_recovery, recovered, - "unexpected recovery metric: {snapshot:#?}" - ); -} - fn lower_span_for(source: &str, needle: &str) -> Result { let start = source .find(needle)