Skip to content

Commit cbddc8b

Browse files
authored
Merge pull request #258 from AdaWorldAPI/claude/cypher-to-arigraph-wire
feat(shader-driver): wire CypherBridge — lg.cypher step_type now routes
2 parents 00a5f8e + b56f7d4 commit cbddc8b

4 files changed

Lines changed: 260 additions & 12 deletions

File tree

.claude/board/AGENT_LOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,3 +265,10 @@ newest-first.** A `BlackboardEntry` by any other transport.
265265
**Commit:** `816a7c0`
266266
**Tests:** 12 pass
267267
**Outcome:** Shipped `lance-graph-archetype` crate scaffold: Component + Processor traits, World meta-state with tick/fork/at_tick stubs, CommandBroker FIFO queue, ArchetypeError. PR #254 merged.
268+
269+
## 2026-04-24T17:30 — Cypher → AriGraph bridge (opus, claude/cypher-to-arigraph-wire)
270+
271+
**D-ids:** CypherBridge, /v1/shader/route lg.cypher handling
272+
**Commit:** `45fc3a4`
273+
**Tests:** 7 pass (create, match, unsupported, non-cypher, missing-reasoning, lowercase, nd-reject)
274+
**Outcome:** Phase 1 stub landed — prefix classifier over step_type="lg.cypher". CREATE and MATCH → Completed (confidence 0.5), other cypher constructs → Skipped with "unsupported cypher construct, stub in place", non-`lg.cypher``Err(DomainUnavailable)` so route_handler falls through to planner. Phase 2 (real `lance_graph::parser::parse_cypher_query` + SPO commit + BindSpace label search) deferred: pulling lance-graph core (arrow + datafusion + lance) into cognitive-shader-driver would balloon build time for what today is a test-path transport. route_handler is now a three-stage chain: CodecResearchBridge (nd.*) → CypherBridge (lg.cypher) → planner_bridge. Live curl against localhost:3001/v1/shader/route verified all four paths: CREATE→completed+0.5, MATCH→completed+0.5, DROP INDEX→skipped, lg.plan→failed (planner not compiled in, unchanged from pre-PR).
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
//! **LAB-ONLY consumer.** `OrchestrationBridge` impl for Cypher queries —
2+
//! owns `StepDomain::LanceGraph` (`lg.cypher` step_type).
3+
//!
4+
//! Minimal viable Cypher path: recognizes the shape of the query via a
5+
//! lightweight classifier and dispatches against the AriGraph SPO triple
6+
//! store / BindSpace. This is the Phase 1 stub — the regex/prefix
7+
//! classifier. Phase 2 (real `lance_graph::parser::parse_cypher_query` wiring)
8+
//! is deferred because pulling the full lance-graph core dep (arrow +
9+
//! datafusion + lance) into `cognitive-shader-driver` would balloon build
10+
//! time for what is today a test-path transport.
11+
//!
12+
//! Contract with the route_handler:
13+
//! - step_type must be `lg.cypher` (or any `lg.cypher.*` refinement).
14+
//! - reasoning field carries the Cypher query string.
15+
//! - Unknown domains bubble back as `DomainUnavailable` so the handler can
16+
//! fall through to the planner bridge.
17+
//!
18+
//! Recognized shapes:
19+
//! - `CREATE (n:Label {k:v})` — reports cypher CREATE parsed (SPO commit
20+
//! pending real wiring).
21+
//! - `MATCH (n:Label) RETURN n` — reports cypher MATCH parsed (BindSpace
22+
//! label search pending real wiring).
23+
//! - Anything else — `StepStatus::Skipped` with "unsupported cypher
24+
//! construct" reasoning. No failure: the downstream can plan around it.
25+
26+
use lance_graph_contract::nars::InferenceType;
27+
use lance_graph_contract::orchestration::{
28+
OrchestrationBridge, OrchestrationError, StepDomain, StepStatus, UnifiedStep,
29+
};
30+
use lance_graph_contract::plan::ThinkingContext;
31+
use lance_graph_contract::thinking::ThinkingStyle;
32+
33+
/// Bridge for `lg.cypher` step_types. Stateless in Phase 1; an SPO store
34+
/// handle slots in here when Phase 2 wires the real parser + BindSpace.
35+
pub struct CypherBridge;
36+
37+
impl OrchestrationBridge for CypherBridge {
38+
fn route(&self, step: &mut UnifiedStep) -> Result<(), OrchestrationError> {
39+
// Only claim `lg.cypher` (and `lg.cypher.*` refinements). Other
40+
// `lg.*` step types (e.g. `lg.plan`, `lg.resonate`) still fall
41+
// through to the planner bridge.
42+
if !step.step_type.starts_with("lg.cypher") {
43+
// Signal domain mismatch so the route_handler falls through.
44+
let domain = StepDomain::from_step_type(&step.step_type)
45+
.unwrap_or(StepDomain::LanceGraph);
46+
return Err(OrchestrationError::DomainUnavailable(domain));
47+
}
48+
49+
step.status = StepStatus::Running;
50+
51+
let query = step
52+
.reasoning
53+
.as_deref()
54+
.ok_or_else(|| {
55+
OrchestrationError::RoutingFailed(
56+
"missing cypher query in reasoning field".to_string(),
57+
)
58+
})?
59+
.trim()
60+
.to_string();
61+
62+
if query.is_empty() {
63+
step.status = StepStatus::Failed;
64+
return Err(OrchestrationError::RoutingFailed(
65+
"empty cypher query".to_string(),
66+
));
67+
}
68+
69+
// Classify the query shape. Case-insensitive match on the leading
70+
// keyword; anything else is Skipped (stub-in-place, not Failed).
71+
let upper = query.to_uppercase();
72+
if upper.starts_with("CREATE") {
73+
step.status = StepStatus::Completed;
74+
step.reasoning = Some(
75+
"cypher CREATE parsed (stub — actual SPO commit pending)".to_string(),
76+
);
77+
step.confidence = Some(0.5);
78+
Ok(())
79+
} else if upper.starts_with("MATCH") {
80+
step.status = StepStatus::Completed;
81+
step.reasoning = Some(
82+
"cypher MATCH parsed (stub — actual BindSpace search pending)".to_string(),
83+
);
84+
step.confidence = Some(0.5);
85+
Ok(())
86+
} else {
87+
step.status = StepStatus::Skipped;
88+
let preview_len = 50.min(query.len());
89+
step.reasoning = Some(format!(
90+
"unsupported cypher construct, stub in place: {}",
91+
&query[..preview_len]
92+
));
93+
Ok(())
94+
}
95+
}
96+
97+
fn resolve_thinking(
98+
&self,
99+
_style: ThinkingStyle,
100+
_inference_type: InferenceType,
101+
) -> ThinkingContext {
102+
ThinkingContext {
103+
style: ThinkingStyle::Systematic,
104+
modulation: Default::default(),
105+
inference_type: InferenceType::Deduction,
106+
strategy: lance_graph_contract::nars::QueryStrategy::CamExact,
107+
semiring: lance_graph_contract::nars::SemiringChoice::HammingMin,
108+
free_will_modifier: 1.0,
109+
exploratory: false,
110+
}
111+
}
112+
113+
fn domain_available(&self, domain: StepDomain) -> bool {
114+
matches!(domain, StepDomain::LanceGraph)
115+
}
116+
}
117+
118+
// ──────────────────────────────────────────────────────────────────────
119+
// Tests
120+
// ──────────────────────────────────────────────────────────────────────
121+
122+
#[cfg(test)]
123+
mod tests {
124+
use super::*;
125+
126+
fn make_step(step_type: &str, reasoning: Option<&str>) -> UnifiedStep {
127+
UnifiedStep {
128+
step_id: "t-1".to_string(),
129+
step_type: step_type.to_string(),
130+
status: StepStatus::Pending,
131+
thinking: None,
132+
reasoning: reasoning.map(|s| s.to_string()),
133+
confidence: None,
134+
}
135+
}
136+
137+
#[test]
138+
fn create_cypher_parses() {
139+
let bridge = CypherBridge;
140+
let mut step = make_step("lg.cypher", Some("CREATE (c:Customer {id:1})"));
141+
let result = bridge.route(&mut step);
142+
assert!(result.is_ok(), "CREATE should be accepted, got {:?}", result);
143+
assert_eq!(step.status, StepStatus::Completed);
144+
assert_eq!(step.confidence, Some(0.5));
145+
assert!(step
146+
.reasoning
147+
.as_deref()
148+
.unwrap_or("")
149+
.contains("CREATE parsed"));
150+
}
151+
152+
#[test]
153+
fn match_cypher_parses() {
154+
let bridge = CypherBridge;
155+
let mut step = make_step("lg.cypher", Some("MATCH (c:Customer) RETURN c"));
156+
let result = bridge.route(&mut step);
157+
assert!(result.is_ok(), "MATCH should be accepted, got {:?}", result);
158+
assert_eq!(step.status, StepStatus::Completed);
159+
assert_eq!(step.confidence, Some(0.5));
160+
assert!(step
161+
.reasoning
162+
.as_deref()
163+
.unwrap_or("")
164+
.contains("MATCH parsed"));
165+
}
166+
167+
#[test]
168+
fn unsupported_cypher_skipped() {
169+
let bridge = CypherBridge;
170+
let mut step = make_step("lg.cypher", Some("DROP INDEX"));
171+
let result = bridge.route(&mut step);
172+
assert!(result.is_ok());
173+
assert_eq!(step.status, StepStatus::Skipped);
174+
assert!(step
175+
.reasoning
176+
.as_deref()
177+
.unwrap_or("")
178+
.contains("unsupported cypher construct"));
179+
}
180+
181+
#[test]
182+
fn non_cypher_rejected() {
183+
let bridge = CypherBridge;
184+
let mut step = make_step("lg.plan", Some("anything"));
185+
let result = bridge.route(&mut step);
186+
match result {
187+
Err(OrchestrationError::DomainUnavailable(_)) => {}
188+
other => panic!("expected DomainUnavailable, got {:?}", other),
189+
}
190+
}
191+
192+
#[test]
193+
fn missing_reasoning_errors() {
194+
let bridge = CypherBridge;
195+
let mut step = make_step("lg.cypher", None);
196+
let result = bridge.route(&mut step);
197+
assert!(matches!(result, Err(OrchestrationError::RoutingFailed(_))));
198+
}
199+
200+
#[test]
201+
fn lowercase_cypher_parses() {
202+
// Case-insensitive keyword match — Cypher is not case-sensitive
203+
// on keywords.
204+
let bridge = CypherBridge;
205+
let mut step = make_step("lg.cypher", Some("match (n) return n"));
206+
let result = bridge.route(&mut step);
207+
assert!(result.is_ok());
208+
assert_eq!(step.status, StepStatus::Completed);
209+
}
210+
211+
#[test]
212+
fn nd_prefix_rejected() {
213+
// Sanity: `nd.*` steps are not this bridge's business.
214+
let bridge = CypherBridge;
215+
let mut step = make_step("nd.tensors", Some("{}"));
216+
let result = bridge.route(&mut step);
217+
match result {
218+
Err(OrchestrationError::DomainUnavailable(_)) => {}
219+
other => panic!("expected DomainUnavailable, got {:?}", other),
220+
}
221+
}
222+
}

crates/cognitive-shader-driver/src/lib.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,13 @@ pub mod codec_research;
166166
#[cfg(any(feature = "serve", feature = "grpc"))]
167167
pub mod codec_bridge;
168168

169+
// OrchestrationBridge impl for Cypher queries (lg.cypher step_type) —
170+
// routes to AriGraph SPO / BindSpace. Phase 1 stub classifier; Phase 2
171+
// will pull the real `lance_graph::parser::parse_cypher_query` once the
172+
// core crate dep is worth its build-time cost. LAB-ONLY.
173+
#[cfg(any(feature = "serve", feature = "grpc"))]
174+
pub mod cypher_bridge;
175+
169176
// Planner bridge — lab test-shortcut for the per-op WirePlan DTOs.
170177
// PlannerAwareness implements OrchestrationBridge directly in the
171178
// planner crate; that's the canonical path. LAB-ONLY.

crates/cognitive-shader-driver/src/serve.rs

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -385,19 +385,31 @@ async fn route_handler(
385385
let codec_bridge = crate::codec_bridge::CodecResearchBridge;
386386
let result = codec_bridge.route(&mut step);
387387

388-
// If codec bridge rejected with DomainUnavailable, try planner bridge (lg.*)
388+
// If codec bridge rejected with DomainUnavailable, try CypherBridge
389+
// (lg.cypher). This keeps the nd.* hot path unchanged while adding
390+
// `lg.cypher` routing ahead of the planner fallthrough.
389391
if matches!(result, Err(lance_graph_contract::orchestration::OrchestrationError::DomainUnavailable(_))) {
390-
#[cfg(feature = "with-planner")]
391-
{
392-
let st = _state.lock().map_err(|_| {
393-
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "lock poisoned"})))
394-
})?;
395-
let _ = OrchestrationBridge::route(&st.planner, &mut step);
396-
}
397-
#[cfg(not(feature = "with-planner"))]
398-
{
399-
step.status = StepStatus::Failed;
400-
step.reasoning = Some("domain unavailable and planner not compiled in".to_string());
392+
let cypher_bridge = crate::cypher_bridge::CypherBridge;
393+
let cypher_result = cypher_bridge.route(&mut step);
394+
395+
// If CypherBridge also rejected with DomainUnavailable, fall
396+
// through to the planner bridge for the remaining `lg.*` space.
397+
if matches!(
398+
cypher_result,
399+
Err(lance_graph_contract::orchestration::OrchestrationError::DomainUnavailable(_))
400+
) {
401+
#[cfg(feature = "with-planner")]
402+
{
403+
let st = _state.lock().map_err(|_| {
404+
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "lock poisoned"})))
405+
})?;
406+
let _ = OrchestrationBridge::route(&st.planner, &mut step);
407+
}
408+
#[cfg(not(feature = "with-planner"))]
409+
{
410+
step.status = StepStatus::Failed;
411+
step.reasoning = Some("domain unavailable and planner not compiled in".to_string());
412+
}
401413
}
402414
}
403415

0 commit comments

Comments
 (0)