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
16 changes: 9 additions & 7 deletions src/bin/pattern_typecheck.rs
Original file line number Diff line number Diff line change
Expand Up @@ -345,10 +345,10 @@ fn main() {
}

println!(
"\n{:<26}{:<11}{:>7}{:>9}{:>13}{:>9}{:>11}",
"case", "category", "exp", "got", "check_med_us", "refines", "edge_scan"
"\n{:<26}{:<11}{:>7}{:>9}{:>13}{:>9}{:>7}{:>11}",
"case", "category", "exp", "got", "check_med_us", "refines", "hits", "edge_scan"
);
println!("{}", "-".repeat(86));
println!("{}", "-".repeat(93));
let mut rows: Vec<Row> = Vec::new();
let mut mismatches = 0usize;
for (id, category, expected, q) in &cases {
Expand Down Expand Up @@ -387,13 +387,14 @@ fn main() {
mismatches += 1;
}
println!(
"{:<26}{:<11}{:>7}{:>9}{:>13.3}{:>9}{:>11}{}",
"{:<26}{:<11}{:>7}{:>9}{:>13.3}{:>9}{:>7}{:>11}{}",
id,
category,
expected,
got,
med as f64 / 1000.0,
st.refine_calls,
st.refine_cache_hits,
st.edge_entries_scanned,
if ok { "" } else { " <-- VERDICT MISMATCH" }
);
Expand All @@ -409,13 +410,13 @@ fn main() {
ok,
});
}
println!("{}", "-".repeat(86));
println!("{}", "-".repeat(93));

let mut f = File::create(&out).unwrap_or_else(|e| panic!("create {out}: {e}"));
writeln!(
f,
"case,category,expected,got,check_med_ns,check_min_ns,\
refine_calls,node_scanned,edge_scanned,refine_to_nodes,\
refine_calls,refine_cache_hits,node_scanned,edge_scanned,refine_to_nodes,\
env_meets,env_unions,env_outer_joins,env_to_groups,pt_meets,\
phase_pattern_ns,phase_rep_ns,phase_group_by_ns,phase_returns_ns,phase_order_by_ns,\
status"
Expand All @@ -424,14 +425,15 @@ fn main() {
for r in &rows {
writeln!(
f,
"{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}",
"{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}",
r.id,
r.category,
r.expected,
r.got,
r.med,
r.min,
r.st.refine_calls,
r.st.refine_cache_hits,
r.st.node_entries_scanned,
r.st.edge_entries_scanned,
r.st.refine_to_nodes_calls,
Expand Down
2 changes: 1 addition & 1 deletion src/typing/descriptor_type.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use super::property_type::PropertyType;

/// Combines a label constraint with a property constraint.
/// Used to describe the type of a node or edge.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct DescriptorType {
pub label: LabelType,
pub props: PropertyType,
Expand Down
2 changes: 1 addition & 1 deletion src/typing/property_type.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use super::simple_type::SimpleType;

/// Property types describe the record structure of node/edge properties.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PropertyType {
/// Open record — allows extra unspecified attributes (returns Star for unknown keys)
Open(BTreeMap<String, SimpleType>),
Expand Down
8 changes: 8 additions & 0 deletions src/typing/stats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ use std::cell::Cell;
pub struct TcStats {
/// Calls to `VariableType::refine` that hit a scan arm (Node or Edge).
pub refine_calls: u64,
/// Scan-arm refine calls answered from the schema's memo cache.
pub refine_cache_hits: u64,
/// Total `schema.nodes` entries walked across all refine Node-arm scans.
pub node_entries_scanned: u64,
/// Total `schema.edges` entries walked across all refine Edge-arm scans.
Expand All @@ -36,6 +38,7 @@ pub struct TcStats {

const ZERO: TcStats = TcStats {
refine_calls: 0,
refine_cache_hits: 0,
node_entries_scanned: 0,
edge_entries_scanned: 0,
refine_to_nodes_calls: 0,
Expand Down Expand Up @@ -85,6 +88,11 @@ pub(crate) fn record_refine_edge_scan(entries: usize) {
});
}

#[inline]
pub(crate) fn record_refine_cache_hit() {
bump(|s| s.refine_cache_hits += 1);
}

#[inline]
pub(crate) fn record_refine_to_nodes() {
bump(|s| s.refine_to_nodes_calls += 1);
Expand Down
60 changes: 57 additions & 3 deletions src/typing/variable_type.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ use super::descriptor_type::DescriptorType;
use super::simple_type::SimpleType;

/// Types for pattern variables (nodes, edges, unions, lists, bottom).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
/// `Hash` keys the per-schema refine memo cache.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum VariableType {
Node(DescriptorType),
EdgeDirectional {
Expand Down Expand Up @@ -304,24 +305,44 @@ impl VariableType {
pub fn refine(schema: &Schema, node: &VariableType) -> VariableType {
match node {
VariableType::Node(_) => {
if !refine_cache_disabled() {
if let Some(hit) = schema.refine_cache_get(node) {
super::stats::record_refine_cache_hit();
return hit;
}
}
super::stats::record_refine_node_scan(schema.nodes.len());
let matches: Vec<VariableType> = schema
.nodes
.iter()
.filter(|n| VariableType::is_subtype(n, node))
.map(|n| VariableType::meet(n, node))
.collect();
VariableType::join_from_list(matches)
let refined = VariableType::join_from_list(matches);
if !refine_cache_disabled() {
schema.refine_cache_put(node.clone(), refined.clone());
}
refined
}
VariableType::EdgeDirectional { .. } | VariableType::EdgeNonDirectional { .. } => {
if !refine_cache_disabled() {
if let Some(hit) = schema.refine_cache_get(node) {
super::stats::record_refine_cache_hit();
return hit;
}
}
super::stats::record_refine_edge_scan(schema.edges.len());
let matches: Vec<VariableType> = schema
.edges
.iter()
.filter(|e| VariableType::is_subtype(e, node))
.map(|e| VariableType::meet(e, node))
.collect();
VariableType::join_from_list(matches)
let refined = VariableType::join_from_list(matches);
if !refine_cache_disabled() {
schema.refine_cache_put(node.clone(), refined.clone());
}
refined
}
VariableType::Union(t1, t2) => VariableType::join(
VariableType::refine(schema, t1),
Expand Down Expand Up @@ -388,8 +409,23 @@ impl fmt::Display for VariableType {
pub struct Schema {
pub nodes: Rc<Vec<VariableType>>,
pub edges: Rc<Vec<VariableType>>,
/// Memo for `VariableType::refine`'s Node/Edge scan arms, keyed by the
/// pattern type being refined. Shared across `Schema::clone` (so every
/// `Typechecker::new(schema.clone())` reuses it — transparently
/// cross-query for REPL/Connection lifetimes) and safely invalidated by
/// construction: DDL and inference replace the whole `Schema`, never
/// mutate one in place, so a cache can never outlive its entries.
/// Skipped by serde — a deserialized schema starts cold.
/// `GQLITE_DISABLE_TC_REFINE_CACHE=1` bypasses it (A/B kill switch).
#[serde(skip, default)]
refine_cache: Rc<std::cell::RefCell<std::collections::HashMap<VariableType, VariableType>>>,
}

/// Safety valve for adversarial/degenerate sessions: the cache resets when
/// it grows past this many distinct pattern descriptors (real queries stay
/// in the dozens).
const REFINE_CACHE_CAP: usize = 4096;

impl Schema {
/// Permissive schema that allows anything.
pub fn star() -> Self {
Expand All @@ -399,6 +435,7 @@ impl Schema {
VariableType::edge_directional(DescriptorType::star()),
VariableType::edge_non_directional(DescriptorType::star()),
]),
refine_cache: Rc::default(),
}
}

Expand All @@ -407,10 +444,27 @@ impl Schema {
Schema {
nodes: Rc::new(nodes),
edges: Rc::new(edges),
refine_cache: Rc::default(),
}
}

fn refine_cache_get(&self, key: &VariableType) -> Option<VariableType> {
self.refine_cache.borrow().get(key).cloned()
}

fn refine_cache_put(&self, key: VariableType, value: VariableType) {
let mut m = self.refine_cache.borrow_mut();
if m.len() >= REFINE_CACHE_CAP {
m.clear();
}
m.insert(key, value);
}
}

fn refine_cache_disabled() -> bool {
std::env::var("GQLITE_DISABLE_TC_REFINE_CACHE").is_ok()
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
82 changes: 82 additions & 0 deletions tests/tc_refine_cache_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
//! Differential suite for the typechecker's refine memo cache
//! (`Schema::refine_cache`): with the cache enabled (default) and disabled
//! (`GQLITE_DISABLE_TC_REFINE_CACHE=1`), `check_query` must produce
//! identical verdicts, errors, and warnings — cold and warm.
//!
//! Kept as a single `#[test]` because the kill switch is a process-global
//! env var and cargo runs tests in threads.

use std::path::Path;

use frogql::store::lazy::LazyGraphStore;
use frogql::typing::checker::Typechecker;
use frogql::typing::variable_type::Schema;

type Verdict = (bool, bool, Vec<String>, Vec<String>);

fn verdict(schema: &Schema, q: &str) -> Verdict {
let parsed = frogql::parser::parse_query(q).expect("parse");
let elab = frogql::elaborate::elaborate_query(parsed);
let mut tc = Typechecker::new(schema.clone());
let r = tc.check_query(&elab);
(r.ok, r.empty, tc.errors.clone(), tc.warnings.clone())
}

const QUERIES: &[&str] = &[
// Unlabeled / star shapes (wide refinements, exercise Union results).
"(a)",
"()-[]->()",
"()-[]->()-[]->()",
"(a)-[e]-(b)",
"(a)~[]~(b)~[]~(c)",
// Labeled shapes — valid or empty depending on the schema; either way
// both cache modes must agree.
"(p: Person)",
"(p: Person)-[]->(m: Movie)",
"(p: Person)-[:ACTED_IN]->(m: Movie)",
"(x: NoSuchLabel)",
"(a: Person)-[:NoSuchEdge]->(b: Person)",
// Repeats, unions, filters.
"(a)-[]->{1,3}(b)",
"(p: Person)-[]->{2,4}(m: Movie)",
"(a: Person) | (a: Movie)",
"(p: Person WHERE p.name = 'Keanu Reeves')",
"(p WHERE p.name = 'x')-[]->(q WHERE q.title = 'y')",
// Full-query surface: RETURN + subqueries + OPTIONAL.
"MATCH (a)-[]->(b) RETURN a",
"MATCH (a) WHERE EXISTS { MATCH (a)-[]->(b) } RETURN a",
"MATCH (a)-[]->(b) OPTIONAL MATCH (b)-[]->(c) RETURN a",
];

fn assert_cache_transparent(schema: &Schema, label: &str) {
for q in QUERIES {
// Cold + warm with the cache on (second run hits the memo).
std::env::remove_var("GQLITE_DISABLE_TC_REFINE_CACHE");
let cold = verdict(schema, q);
let warm = verdict(schema, q);
// Cache off.
std::env::set_var("GQLITE_DISABLE_TC_REFINE_CACHE", "1");
let off = verdict(schema, q);
std::env::remove_var("GQLITE_DISABLE_TC_REFINE_CACHE");
assert_eq!(cold, off, "[{label}] cache-on (cold) != cache-off for: {q}");
assert_eq!(warm, off, "[{label}] cache-on (warm) != cache-off for: {q}");
}
}

#[test]
fn refine_cache_is_transparent() {
// Star schema: everything satisfiable, exercises the Node/Edge arms
// with permissive entries.
assert_cache_transparent(&Schema::star(), "star");

// A data-derived schema from a committed example DB: multiple node and
// edge entries, so refinements produce real unions and real empties.
let lazy =
LazyGraphStore::open(Path::new("examples/movies.gdb")).expect("open examples/movies.gdb");
let schema = lazy.catalog().active_schema();
assert!(
!schema.nodes.is_empty() && !schema.edges.is_empty(),
"movies.gdb should yield a non-trivial inferred schema"
);
assert_cache_transparent(&schema, "movies");
}