tawasuyu

tawasuyu/nakui

> Excel-style reactive engine, on solid principles — exact Decimal, topological cascade, WAL, time-travel — in Rust, with a Llimphi UI.

Rust 82Markdown 14JSON 12TOML 101471 Definitions

⎇ 7041e0499f8449ae8d7bba2a65695d82cef05a9e 01_yachay/nakui/nakui-core/tests/graph.rs Rust · 371 lines · 12.1 KB History Who touched what raw

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