Skip to content

Commit e597bf0

Browse files
rycegclaude
andcommitted
feat(alerts): emit canonical node paths in describe
Every node in describe's tree now carries its path (the same canonical form leaf_paths emits). A sustained node's path equals the key the engine stores its timer under (condition_timers.path) - root is kind.wire(), the engine's own timer-path root, and children use the shared core child_path - so a host joins a duration node straight to its persisted first-true instant for a live 11-of-15-minute countdown without re-deriving paths itself. Leaf-id assignment and the container-unwrap guards are unchanged. Adds a test pinning the emitted paths against the timer-key form. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c784297 commit e597bf0

3 files changed

Lines changed: 60 additions & 13 deletions

File tree

crates/nocturne-alerts-ffi/README.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -299,18 +299,25 @@ Response — a recursive `tree`:
299299
"ok": true,
300300
"tree": {
301301
"type": "composite",
302+
"path": "composite",
302303
"operator": "and", // and | or (null if unset)
303304
"conditions": [
304-
{ "leaf_id": 0, "type": "threshold", "kind": "threshold",
305+
{ "leaf_id": 0, "path": "composite[0].threshold",
306+
"type": "threshold", "kind": "threshold",
305307
"params": { "direction": "below", "value": 80 } },
306-
{ "type": "sustained", "minutes": 15, // container: no leaf_id, carries duration
307-
"child": { "leaf_id": 1, "type": "iob", "kind": "iob",
308+
{ "type": "sustained", "path": "composite[1].sustained", "minutes": 15,
309+
"child": { "leaf_id": 1, "path": "composite[1].sustained[0].iob",
310+
"type": "iob", "kind": "iob",
308311
"params": { "operator": "<", "value": 1 } } }
309312
]
310313
}
311314
}
312315
```
313316

317+
- Every node carries its canonical `path` (the same form `leaf_paths` emits). A
318+
`sustained` node's `path` equals the key the engine stores its timer under
319+
(`condition_timers.path`), so a host joins a duration node straight to its
320+
persisted first-true instant for a "11 of 15 min" countdown.
314321
- **Containers** (`composite`, `not`, `sustained`) carry structure only —
315322
`operator` / `minutes` / nested `conditions`/`child` — and **no `leaf_id`**.
316323
- **Leaves** carry `leaf_id`, the verbatim `type`, the resolved canonical

crates/nocturne-alerts-ffi/src/envelope.rs

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -556,8 +556,12 @@ pub fn describe(request_json: &str) -> Result<Value, String> {
556556
};
557557
let full_node = Node::from_rule(kind, payload);
558558

559+
// Root path is the kind's wire name — the same root the engine keys timers
560+
// and leaf paths under, so a host joins a sustained node's `path` straight
561+
// to its persisted timer (`condition_timers.path`).
562+
let root_path = kind.wire().to_string();
559563
let mut next_leaf_id = 0;
560-
let tree = describe_node(Some(&full_node), &mut next_leaf_id);
564+
let tree = describe_node(Some(&full_node), root_path, &mut next_leaf_id);
561565

562566
Ok(json!({
563567
"schema_version": SCHEMA_VERSION,
@@ -572,11 +576,11 @@ pub fn describe(request_json: &str) -> Result<Value, String> {
572576
/// child/conditions list is missing — is a leaf and takes the next id. A
573577
/// JSON-null composite slot is a typeless leaf, exactly as the engine
574578
/// force-evaluates it to `false`.
575-
fn describe_node(node: Option<&Node>, next_leaf_id: &mut i32) -> Value {
579+
fn describe_node(node: Option<&Node>, path: String, next_leaf_id: &mut i32) -> Value {
576580
let Some(node) = node else {
577581
let id = *next_leaf_id;
578582
*next_leaf_id += 1;
579-
return leaf_value(id, Value::Null, Value::Null, Value::Null);
583+
return leaf_value(id, path, Value::Null, Value::Null, Value::Null);
580584
};
581585
let lower = node.type_str.as_deref().map(str::to_lowercase);
582586
match lower.as_deref() {
@@ -586,10 +590,16 @@ fn describe_node(node: Option<&Node>, next_leaf_id: &mut i32) -> Value {
586590
{
587591
let conditions: Vec<Value> = children
588592
.iter()
589-
.map(|c| describe_node(c.as_ref(), next_leaf_id))
593+
.enumerate()
594+
.map(|(i, c)| {
595+
let cp =
596+
child_path(&path, i, c.as_ref().and_then(|n| n.type_str.as_deref()));
597+
describe_node(c.as_ref(), cp, next_leaf_id)
598+
})
590599
.collect();
591600
return json!({
592601
"type": "composite",
602+
"path": path,
593603
"operator": opt_str(&p.operator),
594604
"conditions": conditions,
595605
});
@@ -599,20 +609,24 @@ fn describe_node(node: Option<&Node>, next_leaf_id: &mut i32) -> Value {
599609
if let Some(Payload::Not(p)) = node.payload("not")
600610
&& let Some(child) = &p.child
601611
{
612+
let cp = child_path(&path, 0, child.type_str.as_deref());
602613
return json!({
603614
"type": "not",
604-
"child": describe_node(Some(child), next_leaf_id),
615+
"path": path,
616+
"child": describe_node(Some(child), cp, next_leaf_id),
605617
});
606618
}
607619
}
608620
Some("sustained") => {
609621
if let Some(Payload::Sustained(p)) = node.payload("sustained")
610622
&& let Some(child) = &p.child
611623
{
624+
let cp = child_path(&path, 0, child.type_str.as_deref());
612625
return json!({
613626
"type": "sustained",
627+
"path": path,
614628
"minutes": p.minutes,
615-
"child": describe_node(Some(child), next_leaf_id),
629+
"child": describe_node(Some(child), cp, next_leaf_id),
616630
});
617631
}
618632
}
@@ -643,14 +657,14 @@ fn describe_node(node: Option<&Node>, next_leaf_id: &mut i32) -> Value {
643657
Some(p) => payload_json(p),
644658
None => payload_json(&default_payload(k)),
645659
};
646-
leaf_value(id, type_value, Value::String(k.wire().to_string()), params)
660+
leaf_value(id, path, type_value, Value::String(k.wire().to_string()), params)
647661
}
648-
None => leaf_value(id, type_value, Value::Null, Value::Null),
662+
None => leaf_value(id, path, type_value, Value::Null, Value::Null),
649663
}
650664
}
651665

652-
fn leaf_value(leaf_id: i32, type_value: Value, kind: Value, params: Value) -> Value {
653-
json!({ "leaf_id": leaf_id, "type": type_value, "kind": kind, "params": params })
666+
fn leaf_value(leaf_id: i32, path: String, type_value: Value, kind: Value, params: Value) -> Value {
667+
json!({ "leaf_id": leaf_id, "path": path, "type": type_value, "kind": kind, "params": params })
654668
}
655669

656670
fn opt_str(s: &Option<String>) -> Value {

crates/nocturne-alerts-ffi/src/tests.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,32 @@ fn describe_composite_preserves_structure_and_sustained_minutes() {
782782
assert_eq!(conds[1]["child"]["kind"], json!("iob"));
783783
}
784784

785+
#[test]
786+
fn describe_emits_paths_that_match_timer_keys() {
787+
// Every node carries its canonical path; a sustained node's path equals the
788+
// key the engine stores its timer under (`condition_timers.path`), so the
789+
// host joins a duration node straight to its persisted first-true instant.
790+
let params = json!({
791+
"operator": "and",
792+
"conditions": [
793+
{ "type": "threshold", "threshold": { "direction": "below", "value": 80 } },
794+
{
795+
"type": "sustained",
796+
"sustained": {
797+
"minutes": 15,
798+
"child": { "type": "iob", "iob": { "operator": "<", "value": 1 } }
799+
}
800+
}
801+
]
802+
});
803+
let tree = describe_rule("composite", params);
804+
assert_eq!(tree["path"], json!("composite"));
805+
assert_eq!(tree["conditions"][0]["path"], json!("composite[0].threshold"));
806+
let sustained = &tree["conditions"][1];
807+
assert_eq!(sustained["path"], json!("composite[1].sustained"));
808+
assert_eq!(sustained["child"]["path"], json!("composite[1].sustained[0].iob"));
809+
}
810+
785811
#[test]
786812
fn describe_leaf_ids_align_with_evaluate_force_eval_log() {
787813
// The whole point of `describe`: its leaf ids must match `evaluate`'s

0 commit comments

Comments
 (0)