tawasuyu

tawasuyu/brahman

Rust 326TOML 123JSON 17Markdown 144606 Definiciones

⎇ d3649bfd1a80153652c2a028315369bb4b184024 crates/modules/nakui/core/tests/graph.rs Rust · 356 líneas · 12.0 KB Historia Quién tocó qué crudo

refactor(nakui-core): KCL → Nickel — kcl_wrapper reemplazado por evaluación in-process b05de24c2 · Sergio ·

1//! ManifestGraph: cycle detection on `depends_on`, data-flow indexes for
2//! `reads`/`writes`, and the `affected_by` query that powers dirty-marking.
3
4use std::path::{Path, PathBuf};
5
6use nakui_core::executor::Executor;
7use nakui_core::graph::{DirtyTracker, GraphError, ManifestGraph};
8use nakui_core::manifest::{
9    ConserveRule, Invariants, Manifest, MorphismInput, MorphismSpec,
10};
11
12fn workspace_root() -> PathBuf {
13    Path::new(env!("CARGO_MANIFEST_DIR"))
14        .parent()
15        .expect("workspace root above core/")
16        .to_path_buf()
17}
18
19fn module(name: &str) -> PathBuf {
20    workspace_root().join("modules").join(name)
21}
22
23fn morphism(name: &str, depends_on: Vec<String>) -> MorphismSpec {
24    MorphismSpec {
25        name: name.into(),
26        inputs: vec![MorphismInput {
27            role: "caja".into(),
28            entity: "Caja".into(),
29        }],
30        reads: vec!["caja.saldo".into()],
31        writes: vec!["caja.saldo".into()],
32        invariants: Invariants::default(),
33        depends_on,
34        script: "morphisms/register_cash_move.rhai".into(),
35    }
36}
37
38fn manifest_with(morphisms: Vec<MorphismSpec>) -> Manifest {
39    Manifest {
40        module: "graph_test".into(),
41        schemas: vec![],
42        morphisms,
43    }
44}
45
46#[test]
47fn detects_two_node_cycle() {
48    let m = manifest_with(vec![
49        morphism("a", vec!["b".into()]),
50        morphism("b", vec!["a".into()]),
51    ]);
52    match ManifestGraph::build(&m) {
53        Err(GraphError::Cycle(names)) => {
54            assert!(names.contains(&"a".to_string()));
55            assert!(names.contains(&"b".to_string()));
56        }
57        other => panic!("expected Cycle, got {:?}", other),
58    }
59}
60
61#[test]
62fn detects_self_loop() {
63    let m = manifest_with(vec![morphism("loop", vec!["loop".into()])]);
64    match ManifestGraph::build(&m) {
65        Err(GraphError::Cycle(names)) => {
66            assert_eq!(names, vec!["loop".to_string()]);
67        }
68        other => panic!("expected Cycle, got {:?}", other),
69    }
70}
71
72#[test]
73fn detects_three_node_cycle() {
74    let m = manifest_with(vec![
75        morphism("a", vec!["b".into()]),
76        morphism("b", vec!["c".into()]),
77        morphism("c", vec!["a".into()]),
78    ]);
79    match ManifestGraph::build(&m) {
80        Err(GraphError::Cycle(names)) => {
81            assert_eq!(names.len(), 3);
82        }
83        other => panic!("expected Cycle, got {:?}", other),
84    }
85}
86
87#[test]
88fn topological_order_respects_explicit_dependencies() {
89    // a <- b <- c (c depends on b depends on a)
90    let m = manifest_with(vec![
91        morphism("a", vec![]),
92        morphism("b", vec!["a".into()]),
93        morphism("c", vec!["b".into()]),
94    ]);
95    let g = ManifestGraph::build(&m).expect("acyclic");
96    let order = g.topological_order();
97    let pos = |n: &str| order.iter().position(|x| x == n).unwrap();
98    assert!(pos("a") < pos("b"));
99    assert!(pos("b") < pos("c"));
100}
101
102#[test]
103fn unknown_depends_on_target_errors() {
104    let m = manifest_with(vec![morphism("a", vec!["ghost".into()])]);
105    match ManifestGraph::build(&m) {
106        Err(GraphError::UnknownMorphism(name)) => assert_eq!(name, "ghost"),
107        other => panic!("expected UnknownMorphism, got {:?}", other),
108    }
109}
110
111#[test]
112fn treasury_data_flow_indexes_match_manifest() {
113    let exec = Executor::load_module(module("treasury")).expect("load");
114    let g = &exec.graph;
115
116    // Both register_cash_move and transfer_between_cajas write Caja.saldo.
117    let mut writers: Vec<&str> = g.writers_of("Caja.saldo").iter().map(|s| s.as_str()).collect();
118    writers.sort();
119    assert_eq!(writers, vec!["register_cash_move", "transfer_between_cajas"]);
120
121    // Both read Caja.saldo too.
122    let mut readers: Vec<&str> = g.readers_of("Caja.saldo").iter().map(|s| s.as_str()).collect();
123    readers.sort();
124    assert_eq!(readers, vec!["register_cash_move", "transfer_between_cajas"]);
125
126    // Movimiento is written only by register_cash_move.
127    assert_eq!(
128        g.writers_of("Movimiento"),
129        &["register_cash_move".to_string()]
130    );
131
132    // Transferencia is written only by transfer_between_cajas.
133    assert_eq!(
134        g.writers_of("Transferencia"),
135        &["transfer_between_cajas".to_string()]
136    );
137
138    // Nothing in treasury reads Movimiento or Transferencia.
139    assert!(g.readers_of("Movimiento").is_empty());
140    assert!(g.readers_of("Transferencia").is_empty());
141}
142
143#[test]
144fn affected_by_excludes_self_and_finds_overlap() {
145    // A simple two-morphism manifest where one writes what the other reads.
146    let m = manifest_with(vec![
147        MorphismSpec {
148            name: "writer".into(),
149            inputs: vec![MorphismInput {
150                role: "caja".into(),
151                entity: "Caja".into(),
152            }],
153            reads: vec![],
154            writes: vec!["caja.saldo".into()],
155            invariants: Invariants::default(),
156            depends_on: vec![],
157            script: "morphisms/register_cash_move.rhai".into(),
158        },
159        MorphismSpec {
160            name: "reader".into(),
161            inputs: vec![MorphismInput {
162                role: "caja".into(),
163                entity: "Caja".into(),
164            }],
165            reads: vec!["caja.saldo".into()],
166            writes: vec![],
167            invariants: Invariants::default(),
168            depends_on: vec![],
169            script: "morphisms/register_cash_move.rhai".into(),
170        },
171        MorphismSpec {
172            name: "self_loop".into(),
173            inputs: vec![MorphismInput {
174                role: "caja".into(),
175                entity: "Caja".into(),
176            }],
177            reads: vec!["caja.saldo".into()],
178            writes: vec!["caja.saldo".into()],
179            invariants: Invariants::default(),
180            depends_on: vec![],
181            script: "morphisms/register_cash_move.rhai".into(),
182        },
183    ]);
184    let g = ManifestGraph::build(&m).expect("acyclic");
185
186    let mut affected = g.affected_by("writer");
187    affected.sort();
188    // writer writes Caja.saldo; readers are reader + self_loop, but
189    // self_loop is "writer"? no, self_loop is a separate morphism here,
190    // and it does read Caja.saldo so it's affected by writer.
191    assert_eq!(affected, vec!["reader", "self_loop"]);
192
193    // self_loop writes its own field but should not list itself.
194    let affected_self = g.affected_by("self_loop");
195    assert_eq!(affected_self, vec!["reader"]);
196}
197
198#[test]
199fn cross_module_graph_canonicalizes_to_entity_tokens() {
200    // sales/vender uses role "stock" (entity Stock) and role "caja" (entity Caja).
201    // Reads and writes should canonicalize to "Stock.cantidad" and "Caja.saldo".
202    let exec = Executor::load_module(module("sales")).expect("load sales");
203    let g = &exec.graph;
204
205    assert_eq!(g.writers_of("Stock.cantidad"), &["vender".to_string()]);
206    assert_eq!(g.writers_of("Caja.saldo"), &["vender".to_string()]);
207    assert_eq!(g.writers_of("Venta"), &["vender".to_string()]);
208
209    let reads = g.morphism_reads("vender");
210    assert!(reads.contains(&"Stock.cantidad".to_string()));
211    assert!(reads.contains(&"Caja.saldo".to_string()));
212    assert!(reads.contains(&"Caja.currency".to_string()));
213}
214
215#[test]
216fn executor_load_module_rejects_cyclic_manifest() {
217    // Synthesize a tempdir with a cyclic manifest and confirm Executor
218    // surfaces ExecError::Graph rather than running.
219    let tmp = std::env::temp_dir().join(format!("nakui_cycle_{}", uuid::Uuid::new_v4()));
220    std::fs::create_dir_all(tmp.join("morphisms")).unwrap();
221    std::fs::write(
222        tmp.join("schema.ncl"),
223        // Schema Nickel mínimo (top-level Caja con saldo >= 0).
224        "{\n  Caja = {\n    saldo | std.contract.from_predicate (fun n => std.is_number n && n >= 0),\n  },\n}\n",
225    )
226    .unwrap();
227    std::fs::write(tmp.join("morphisms/op.rhai"), "[]").unwrap();
228    std::fs::write(
229        tmp.join("nsmc.json"),
230        r#"{
231            "module": "cycle",
232            "morphisms": [
233                {"name": "a", "inputs": [{"role":"caja","entity":"Caja"}],
234                 "reads": [], "writes": ["caja.saldo"], "depends_on": ["b"],
235                 "script": "morphisms/op.rhai"},
236                {"name": "b", "inputs": [{"role":"caja","entity":"Caja"}],
237                 "reads": [], "writes": ["caja.saldo"], "depends_on": ["a"],
238                 "script": "morphisms/op.rhai"}
239            ]
240        }"#,
241    )
242    .unwrap();
243
244    let err = match Executor::load_module(&tmp) {
245        Ok(_) => panic!("must fail with cycle"),
246        Err(e) => e,
247    };
248    let msg = err.to_string();
249    assert!(msg.contains("graph") || msg.contains("cycle"),
250        "expected graph diagnostic, got `{}`", msg);
251
252    let _ = std::fs::remove_dir_all(&tmp);
253}
254
255#[test]
256fn dirty_tracker_marks_after_treasury_morphism() {
257    let exec = Executor::load_module(module("treasury")).expect("load");
258    let mut tracker = DirtyTracker::new();
259
260    // register_cash_move writes Caja.saldo + Movimiento. Both are read by
261    // transfer_between_cajas (Caja.saldo) but Movimiento is read by no one.
262    tracker.mark_dirty_after("register_cash_move", &exec.graph);
263
264    let dirty = tracker.dirty();
265    assert!(
266        dirty.contains(&"transfer_between_cajas".to_string()),
267        "transfer_between_cajas reads Caja.saldo, must be dirty after deposit; got {:?}",
268        dirty
269    );
270    assert!(
271        !tracker.is_dirty("register_cash_move"),
272        "self should not be marked dirty by its own write"
273    );
274}
275
276#[test]
277fn dirty_tracker_clear_works() {
278    let exec = Executor::load_module(module("treasury")).expect("load");
279    let mut tracker = DirtyTracker::new();
280    tracker.mark_dirty_after("transfer_between_cajas", &exec.graph);
281    let count_before = tracker.len();
282    assert!(count_before > 0);
283
284    let first = tracker.dirty().into_iter().next().unwrap();
285    tracker.clear(&first);
286    assert!(!tracker.is_dirty(&first));
287    assert_eq!(tracker.len(), count_before - 1);
288}
289
290#[test]
291fn dirty_tracker_accumulates_across_morphisms() {
292    // Manifest with three morphisms where each writes what the next reads.
293    // After running A then B, both readers should be marked.
294    let m = manifest_with(vec![
295        MorphismSpec {
296            name: "writer_a".into(),
297            inputs: vec![MorphismInput {
298                role: "caja".into(),
299                entity: "Caja".into(),
300            }],
301            reads: vec![],
302            writes: vec!["caja.saldo".into()],
303            invariants: Invariants::default(),
304            depends_on: vec![],
305            script: "morphisms/register_cash_move.rhai".into(),
306        },
307        MorphismSpec {
308            name: "writer_b".into(),
309            inputs: vec![MorphismInput {
310                role: "caja".into(),
311                entity: "Caja".into(),
312            }],
313            reads: vec![],
314            writes: vec!["Movimiento".into()],
315            invariants: Invariants::default(),
316            depends_on: vec![],
317            script: "morphisms/register_cash_move.rhai".into(),
318        },
319        MorphismSpec {
320            name: "reader_caja".into(),
321            inputs: vec![MorphismInput {
322                role: "caja".into(),
323                entity: "Caja".into(),
324            }],
325            reads: vec!["caja.saldo".into()],
326            writes: vec![],
327            invariants: Invariants::default(),
328            depends_on: vec![],
329            script: "morphisms/register_cash_move.rhai".into(),
330        },
331        MorphismSpec {
332            name: "reader_mov".into(),
333            inputs: vec![MorphismInput {
334                role: "caja".into(),
335                entity: "Caja".into(),
336            }],
337            reads: vec!["Movimiento".into()],
338            writes: vec![],
339            invariants: Invariants::default(),
340            depends_on: vec![],
341            script: "morphisms/register_cash_move.rhai".into(),
342        },
343    ]);
344    let g = ManifestGraph::build(&m).unwrap();
345    let mut tracker = DirtyTracker::new();
346
347    tracker.mark_dirty_after("writer_a", &g);
348    assert!(tracker.is_dirty("reader_caja"));
349    assert!(!tracker.is_dirty("reader_mov"));
350
351    tracker.mark_dirty_after("writer_b", &g);
352    assert!(tracker.is_dirty("reader_caja"));
353    assert!(tracker.is_dirty("reader_mov"));
354
355    assert_eq!(tracker.len(), 2);
356}