chore: absorbe nakui (ERP matemático) en modules/nakui

- crates/modules/nakui/core/: el crate nakui-core (4 bins, tests).
  Deps directas (serde, rhai, surrealdb, petgraph, sha2, uuid, tokio,
  thiserror v1) — no convertidas a workspace = true en esta pasada.
- crates/modules/nakui/modules/{inventory,sales,treasury}/: datos
  declarativos del dominio (nsmc.json, schema.k, morphisms/) que el
  crate consume — no son crates.

cargo check -p nakui-core: 0 errores.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-05-08 05:49:58 +00:00
parent 53dbdf0f1d
commit 4d50bfc587
49 changed files with 11953 additions and 40 deletions
+277
View File
@@ -0,0 +1,277 @@
//! Static dependency graph derived from a `Manifest`.
//!
//! Two graphs in one structure:
//! - **Explicit graph** (`depends_on`): morphism-to-morphism edges declared
//! by the manifest author. Cycles here are an error — the graph is built
//! with cycle detection.
//! - **Data-flow indexes** (`reads`/`writes`): inverted indexes from
//! canonical entity tokens (`"Caja.saldo"` or `"Movimiento"`) to the
//! morphisms that read or write them. Self-loops in data flow are
//! legal (a morphism that reads a field and updates it is normal).
//!
//! Tokens are normalized at build time: a manifest's role-prefixed tokens
//! (`"caja.saldo"`) become entity-prefixed (`"Caja.saldo"`) so cross-module
//! queries work uniformly.
use petgraph::algo::tarjan_scc;
use petgraph::graph::{DiGraph, NodeIndex};
use petgraph::visit::Topo;
use std::collections::{HashMap, HashSet};
use thiserror::Error;
use crate::manifest::Manifest;
#[derive(Debug, Error)]
pub enum GraphError {
#[error("dependency cycle in `depends_on` involving morphisms {0:?}")]
Cycle(Vec<String>),
#[error("morphism `{0}` referenced in depends_on but not declared in this manifest")]
UnknownMorphism(String),
}
#[derive(Debug)]
pub struct ManifestGraph {
/// Explicit `depends_on` graph. Edge `a -> b` means: morphism `b`
/// depends on `a`, so `a` must be available before `b`.
explicit: DiGraph<String, ()>,
/// Data-flow indexes. Token form: "Entity.field" or "Entity".
readers_of_token: HashMap<String, Vec<String>>,
writers_of_token: HashMap<String, Vec<String>>,
/// Per-morphism canonicalized token sets.
morphism_reads: HashMap<String, Vec<String>>,
morphism_writes: HashMap<String, Vec<String>>,
}
impl ManifestGraph {
pub fn build(manifest: &Manifest) -> Result<Self, GraphError> {
let explicit = build_explicit(manifest)?;
if let Some(cycle) = find_cycle(&explicit) {
return Err(GraphError::Cycle(cycle));
}
let (readers_of_token, writers_of_token, morphism_reads, morphism_writes) =
build_data_flow(manifest);
Ok(Self {
explicit,
readers_of_token,
writers_of_token,
morphism_reads,
morphism_writes,
})
}
/// Morphisms that read `token`. Token form: "Entity.field" or "Entity".
pub fn readers_of(&self, token: &str) -> &[String] {
self.readers_of_token
.get(token)
.map(|v| v.as_slice())
.unwrap_or(&[])
}
/// Morphisms that write `token`.
pub fn writers_of(&self, token: &str) -> &[String] {
self.writers_of_token
.get(token)
.map(|v| v.as_slice())
.unwrap_or(&[])
}
pub fn morphism_reads(&self, name: &str) -> &[String] {
self.morphism_reads
.get(name)
.map(|v| v.as_slice())
.unwrap_or(&[])
}
pub fn morphism_writes(&self, name: &str) -> &[String] {
self.morphism_writes
.get(name)
.map(|v| v.as_slice())
.unwrap_or(&[])
}
/// Morphisms whose `reads` overlap any of `name`'s `writes`. The
/// dirty-marking primitive: after `name` runs successfully, these are
/// the candidates whose derived state would be invalidated. The result
/// excludes `name` itself even if it reads what it writes.
pub fn affected_by(&self, name: &str) -> Vec<String> {
let writes = match self.morphism_writes.get(name) {
Some(w) => w,
None => return Vec::new(),
};
let mut affected: HashSet<String> = HashSet::new();
for token in writes {
if let Some(readers) = self.readers_of_token.get(token) {
for r in readers {
if r != name {
affected.insert(r.clone());
}
}
}
}
let mut out: Vec<_> = affected.into_iter().collect();
out.sort();
out
}
/// Topological order of the explicit dependency graph. If `a` is in
/// `b.depends_on`, `a` precedes `b` in the result.
pub fn topological_order(&self) -> Vec<String> {
let mut topo = Topo::new(&self.explicit);
let mut out = Vec::new();
while let Some(idx) = topo.next(&self.explicit) {
out.push(self.explicit[idx].clone());
}
out
}
}
fn build_explicit(manifest: &Manifest) -> Result<DiGraph<String, ()>, GraphError> {
let mut graph = DiGraph::new();
let mut nodes: HashMap<String, NodeIndex> = HashMap::new();
for m in &manifest.morphisms {
let idx = graph.add_node(m.name.clone());
nodes.insert(m.name.clone(), idx);
}
for m in &manifest.morphisms {
let to = nodes[&m.name];
for dep in &m.depends_on {
let from = *nodes
.get(dep)
.ok_or_else(|| GraphError::UnknownMorphism(dep.clone()))?;
graph.add_edge(from, to, ());
}
}
Ok(graph)
}
/// Returns one cycle's nodes (sorted) if the graph has any. Self-loops
/// are returned as `[name]`; multi-node SCCs as the SCC's nodes.
fn find_cycle(graph: &DiGraph<String, ()>) -> Option<Vec<String>> {
for scc in tarjan_scc(graph) {
if scc.len() > 1 {
let mut names: Vec<String> = scc.iter().map(|i| graph[*i].clone()).collect();
names.sort();
return Some(names);
}
if scc.len() == 1 && graph.find_edge(scc[0], scc[0]).is_some() {
return Some(vec![graph[scc[0]].clone()]);
}
}
None
}
fn build_data_flow(
manifest: &Manifest,
) -> (
HashMap<String, Vec<String>>,
HashMap<String, Vec<String>>,
HashMap<String, Vec<String>>,
HashMap<String, Vec<String>>,
) {
let mut readers: HashMap<String, Vec<String>> = HashMap::new();
let mut writers: HashMap<String, Vec<String>> = HashMap::new();
let mut m_reads: HashMap<String, Vec<String>> = HashMap::new();
let mut m_writes: HashMap<String, Vec<String>> = HashMap::new();
for m in &manifest.morphisms {
let role_to_entity: HashMap<&str, &str> = m
.inputs
.iter()
.map(|i| (i.role.as_str(), i.entity.as_str()))
.collect();
// Dedupe per-morphism: `source.saldo` and `dest.saldo` both
// canonicalize to `Caja.saldo` — the morphism is one writer, not
// two.
let mut seen_reads: HashSet<String> = HashSet::new();
for r in &m.reads {
if let Some(token) = canonicalize_token(r, &role_to_entity) {
if seen_reads.insert(token.clone()) {
readers.entry(token.clone()).or_default().push(m.name.clone());
m_reads.entry(m.name.clone()).or_default().push(token);
}
}
}
let mut seen_writes: HashSet<String> = HashSet::new();
for w in &m.writes {
if let Some(token) = canonicalize_token(w, &role_to_entity) {
if seen_writes.insert(token.clone()) {
writers.entry(token.clone()).or_default().push(m.name.clone());
m_writes.entry(m.name.clone()).or_default().push(token);
}
}
}
}
(readers, writers, m_reads, m_writes)
}
/// "role.field" -> "Entity.field" via the inputs map; "Entity" -> "Entity".
fn canonicalize_token(t: &str, roles: &HashMap<&str, &str>) -> Option<String> {
if let Some((role, field)) = t.split_once('.') {
roles
.get(role)
.map(|entity| format!("{}.{}", entity, field))
} else {
Some(t.to_string())
}
}
/// Tracks which morphisms have stale derived state because some morphism
/// they read from was applied. Wire it next to your `execute_and_log`
/// loop: after a successful run, call `mark_dirty_after(morphism, &graph)`;
/// then any consumer (cached view, derived report, downstream pipeline)
/// queries `is_dirty(name)` before using its cached output.
///
/// The tracker holds names only — it doesn't know what "recompute" means
/// for any particular morphism. That's deliberate: the kernel exposes the
/// invalidation primitive; what to do with the dirty set is the caller's.
#[derive(Debug, Default, Clone)]
pub struct DirtyTracker {
dirty: HashSet<String>,
}
impl DirtyTracker {
pub fn new() -> Self {
Self::default()
}
/// After `morphism_name` runs successfully, mark every morphism in
/// `graph.affected_by(morphism_name)` as dirty.
pub fn mark_dirty_after(&mut self, morphism_name: &str, graph: &ManifestGraph) {
for affected in graph.affected_by(morphism_name) {
self.dirty.insert(affected);
}
}
pub fn is_dirty(&self, morphism: &str) -> bool {
self.dirty.contains(morphism)
}
/// Sorted list of dirty morphisms. Stable order for UI/telemetry.
pub fn dirty(&self) -> Vec<String> {
let mut out: Vec<String> = self.dirty.iter().cloned().collect();
out.sort();
out
}
pub fn len(&self) -> usize {
self.dirty.len()
}
pub fn is_empty(&self) -> bool {
self.dirty.is_empty()
}
/// Clear the dirty flag for a specific morphism (call after the
/// caller has recomputed it).
pub fn clear(&mut self, morphism: &str) {
self.dirty.remove(morphism);
}
pub fn clear_all(&mut self) {
self.dirty.clear();
}
}