chore: refresco desde el monorepo (pin tawasuyu.git v0.2.0)

Regenerado con scripts/actualizar-standalone.py: código del dominio al día,
git-deps repineadas a v0.2.0 y doble-fuente resuelta con [patch] al source git.
cargo check --workspace verde.
This commit is contained in:
Sergio
2026-07-25 21:03:54 +00:00
parent 958e62665e
commit bb86cfaeae
39 changed files with 4691 additions and 1130 deletions
+1 -1
View File
@@ -185,7 +185,7 @@ DEUDA / ASPIRA_A:
#5 COHERENCIA cross-dominio: rimay/shuma/arje aún no integrados a fondo con nouser.
NORTE_ARQUITECTÓNICO:
Dos planos que convergen. PLANO DE TIPOS (Brahman): "decláme qué consumís y te encuentro quién lo produce,
Dos planos que convergen. PLANO DE TIPOS (Brahman): "decláme qué consumes y te encuentro quién lo produce,
determinista y observable, sin acoplar módulos". PLANO DE DATOS (Nouser): "dame una carpeta y te devuelvo su
estructura semántica latente (Mónadas) con embeddings intercambiables".
El destino es que el broker deje de ser local (Unix socket) y, sobre transporte minga/libp2p, haga discovery
+24 -22
View File
@@ -1,41 +1,43 @@
# chasqui
> `chasqui` (quechua: *mensajero del camino del inca*). Broker de mensajería + bus tipado.
> `chasqui` (quechua: *mensajero del camino del inca*). Broker de tipos + Mónadas semánticas.
Sistema nervioso del monorepo. Apps publican y se suscriben a topics tipados; el broker rutea y persiste. Backend `nous` con dos implementaciones: `mock` (in-process para tests) y `real` (TCP + binary). Cada mensaje lleva su schema, fail-closed si el receptor no lo conoce.
Dominio dual (ver `ARQUITECTURA.md`, el doc técnico autoritativo). **Brahman**: broker de tipos determinista — los módulos (Cards) declaran flujos tipados de entrada/salida y el broker matchea consumer↔producer (matching exact + structural, prioridades, sesgos por contexto) sin mover datos. **Nouser**: inteligencia de datos — escanea directorios en Mónadas (clusters semánticos de archivos) enriquecidas por proveedores de embeddings intercambiables tras el contrato `nous`: `mock` (32d determinista) y `real` (ONNX 384d). El discovery de consumidores es local-first con fallback DHT (`card-sidecar::discovery::resolve_provider`), más connect-and-consume remoto por libp2p (`consume_remote`).
## Instalación
```sh
# arrancar el broker
cargo run --release -p chasqui-broker
# explorer (ver topics + mensajes en vivo)
cargo run --release -p chasqui-broker-explorer-llimphi
cargo run --release -p chasqui-explorer-llimphi
cargo run --release -p chasqui-core --bin chasqui # CLI Nouser: scan|show|json|daemon|attract
cargo run --release -p chasqui-broker-explorer-llimphi # UI probe del broker
cargo run --release -p chasqui-explorer-llimphi # UI explorador de Mónadas
```
El broker Brahman es una librería hospedada por el init de arje (`arje-zero`), no un binario standalone.
## Compatibilidad
- **Linux / macOS / Windows** — broker + clientes en Rust nativo.
- **Wawa** — broker corre como app del kernel (`apps/`).
- TCP localhost por default; sockets Unix opcionales.
- **Linux / macOS** — Rust nativo; handshake sobre sockets Unix.
- **Remoto** — handshake sobre stream libp2p (`card-net`): relay + dcutr + autonat, el NAT ya no bloquea.
- El broker vive en la memoria del Init — efímero por diseño, sin snapshot/recover.
## Crates
| Crate | Rol |
|---|---|
| [`chasqui-core`](chasqui-core/README.md) | Tipos: Topic, Message, Schema, Subscription. |
| [`chasqui-broker`](chasqui-broker/README.md) | Binario del broker. |
| [`chasqui-nous`](chasqui-nous/README.md) | Trait del transport. |
| [`chasqui-nous-mock`](chasqui-nous-mock/README.md) | Transport in-process para tests. |
| [`chasqui-nous-real`](chasqui-nous-real/README.md) | Transport TCP/Unix binario. |
| [`chasqui-card`](chasqui-card/README.md) | Card escritorio (estado del broker). |
| [`chasqui-broker-explorer-llimphi`](chasqui-broker-explorer-llimphi/README.md) | UI: topics + suscriptores activos. |
| [`chasqui-explorer-llimphi`](chasqui-explorer-llimphi/README.md) | UI: log de mensajes en vivo. |
| [`chasqui-broker`](chasqui-broker/README.md) | Brahman: librería de matching de tipos (Exact/Structural, prioridades, contextos). |
| [`card-handshake`](card-handshake/) | Handshake Init↔módulo: Unix socket local, stream libp2p remoto. |
| [`card-sidecar`](card-sidecar/) | Mantiene viva la sesión + discovery (`resolve_provider`, `consume_remote`). |
| [`card-admin`](card-admin/) | Snapshot del estado del broker (sesiones + matches) — `brahman-status`. |
| [`chasqui-core`](chasqui-core/README.md) | Nouser: scanner, clustering determinista, MonadDb, CLI `chasqui`. |
| [`chasqui-card`](chasqui-card/README.md) | Manifiesto de Mónada + cliente de query (`resolve_monad`). |
| [`chasqui-nous`](chasqui-nous/README.md) | Contrato Nous: JSON line-delimited sobre Unix socket. |
| [`chasqui-nous-mock`](chasqui-nous-mock/README.md) | Proveedor de pseudo-embeddings 32d determinista. |
| [`chasqui-nous-real`](chasqui-nous-real/README.md) | Proveedor de embeddings 384d ONNX (feature `embeddings`). |
| [`chasqui-broker-explorer-llimphi`](chasqui-broker-explorer-llimphi/README.md) | UI probe del broker: estado + timeline de matches. |
| [`chasqui-explorer-llimphi`](chasqui-explorer-llimphi/README.md) | UI exploradora de Mónadas con búsqueda semántica. |
## Consideraciones
- **Schema-first.** Sin schema declarado, ningún mensaje pasa.
- **Persistencia opt-in** por topic; los topics efímeros viven sólo en memoria.
- **No es Kafka.** Diseñado para el monorepo, no para volumen de producción interplanetaria.
- **El broker matchea tipos, no mueve datos.** Cada módulo abre su propio data plane (`service_socket`).
- **Efímero por diseño.** El broker es el registro en memoria del Init de qué está vivo *ahora* — no es deuda de persistencia.
- **No es un bus pub/sub.** Esa aspiración original migró al dominio Ayni; chasqui no transporta mensajes app↔app en tiempo real.
+24 -19
View File
@@ -1,38 +1,43 @@
# chasqui
> `chasqui` (Quechua: *messenger of the Inca road*). Message broker + typed bus.
> `chasqui` (Quechua: *messenger of the Inca road*). Type broker + semantic Monads.
Nervous system of the monorepo. Apps publish and subscribe to typed topics; the broker routes and persists. `nous` backend with two impls: `mock` (in-process for tests) and `real` (binary TCP). Every message carries its schema, fail-closed if the receiver doesn't know it.
Dual domain (see `ARQUITECTURA.md`, the authoritative technical doc). **Brahman**: a deterministic type broker — modules (Cards) declare typed input/output flows and the broker matches consumer↔producer (exact + structural matching, priorities, per-context biases) without moving any data. **Nouser**: data intelligence — scans directories into Monads (semantic file clusters) enriched by swappable embedding providers behind the `nous` contract: `mock` (deterministic 32d) and `real` (ONNX 384d). Consumer discovery is local-first with DHT fallback (`card-sidecar::discovery::resolve_provider`), plus remote connect-and-consume over libp2p (`consume_remote`).
## Install
```sh
cargo run --release -p chasqui-broker
cargo run --release -p chasqui-broker-explorer-llimphi
cargo run --release -p chasqui-explorer-llimphi
cargo run --release -p chasqui-core --bin chasqui # Nouser CLI: scan|show|json|daemon|attract
cargo run --release -p chasqui-broker-explorer-llimphi # broker probe UI
cargo run --release -p chasqui-explorer-llimphi # Monad explorer UI
```
The Brahman broker is a library hosted by arje's init (`arje-zero`), not a standalone binary.
## Compatibility
- **Linux / macOS / Windows** — broker + clients in native Rust.
- **Wawa** — broker runs as a kernel app.
- TCP localhost by default; Unix sockets optional.
- **Linux / macOS** — native Rust; handshake over Unix sockets.
- **Remote** — handshake over libp2p stream (`card-net`): relay + dcutr + autonat, NAT no longer blocks.
- The broker lives in the Init's memory — ephemeral by design, no snapshot/recover.
## Crates
| Crate | Role |
|---|---|
| [`chasqui-core`](chasqui-core/README.md) | Topic, Message, Schema, Subscription. |
| [`chasqui-broker`](chasqui-broker/README.md) | Broker binary. |
| [`chasqui-nous`](chasqui-nous/README.md) | Transport trait. |
| [`chasqui-nous-mock`](chasqui-nous-mock/README.md) | In-process transport. |
| [`chasqui-nous-real`](chasqui-nous-real/README.md) | Binary TCP/Unix transport. |
| [`chasqui-card`](chasqui-card/README.md) | Desktop card. |
| [`chasqui-broker-explorer-llimphi`](chasqui-broker-explorer-llimphi/README.md) | Topics + active subscribers UI. |
| [`chasqui-explorer-llimphi`](chasqui-explorer-llimphi/README.md) | Live message log UI. |
| [`chasqui-broker`](chasqui-broker/README.md) | Brahman: type-matching library (Exact/Structural, priorities, contexts). |
| [`card-handshake`](card-handshake/) | Init↔module handshake: Unix socket local, libp2p stream remote. |
| [`card-sidecar`](card-sidecar/) | Keeps the session alive + discovery (`resolve_provider`, `consume_remote`). |
| [`card-admin`](card-admin/) | Broker state snapshot (sessions + matches) — `brahman-status`. |
| [`chasqui-core`](chasqui-core/README.md) | Nouser: scanner, deterministic clustering, MonadDb, `chasqui` CLI. |
| [`chasqui-card`](chasqui-card/README.md) | Monad manifest + query client (`resolve_monad`). |
| [`chasqui-nous`](chasqui-nous/README.md) | Nous contract: JSON line-delimited over Unix socket. |
| [`chasqui-nous-mock`](chasqui-nous-mock/README.md) | Deterministic 32d pseudo-embeddings provider. |
| [`chasqui-nous-real`](chasqui-nous-real/README.md) | 384d ONNX embeddings provider (`embeddings` feature). |
| [`chasqui-broker-explorer-llimphi`](chasqui-broker-explorer-llimphi/README.md) | Broker probe UI: status + match timeline. |
| [`chasqui-explorer-llimphi`](chasqui-explorer-llimphi/README.md) | Monad explorer UI with semantic search. |
## Considerations
- **Schema-first.** No schema declared, no message through.
- **Persistence opt-in** per topic; ephemeral topics live in memory only.
- **Not Kafka.** Designed for the monorepo, not interplanetary production volume.
- **The broker matches types, it does not move data.** Each module opens its own data plane (`service_socket`).
- **Ephemeral by design.** The broker is the Init's in-memory registry of what's alive *now* — no persistence debt.
- **Not a pub/sub bus.** That original aspiration migrated to the Ayni domain; chasqui carries no app↔app messages in real time.
+18
View File
@@ -0,0 +1,18 @@
# card-admin
*Read this in English: [README.md](README.md).*
`brahman-admin` — observabilidad del broker.
Expone un Unix socket separado (no se mezcla con el handshake) en el
que cada conexión recibe un `StatusSnapshot` JSON y se cierra. Es
single-shot por conexión: pensado para herramientas como
`brahman-status`, dashboards y health-checks.
Wire format: una línea JSON por conexión, terminada en `\n`. Esto
hace trivial inspeccionar con `nc` o `socat` además del cliente
tipado de este crate.
---
Parte de **chasqui** — ver [chasqui](../LEEME.md).
+11
View File
@@ -0,0 +1,11 @@
# card-admin
`brahman-admin` — the broker's observability.
It exposes a separate Unix socket (not mixed with the handshake) where each
connection receives a JSON `StatusSnapshot` and is closed. It is single-shot per
connection: meant for tools that poll.
---
Part of **chasqui** — see [chasqui](../README.md).
+23
View File
@@ -0,0 +1,23 @@
# card-handshake
*Read this in English: [README.md](README.md).*
`brahman-handshake` — protocolo runtime Init↔módulo sobre Unix socket.
Implementa la versión concreta de `shared_wit/protocol.wit` (handshake +
lifecycle): un servidor que vive en el Init (o un Admin proxy) y clientes
que son los módulos Brahman. Cada conexión arranca con un `Hello` que
lleva una `card_core::Card`; el servidor valida la Card, deriva el
`TrustLevel`, emite un `HelloAck` con `session-id` ULID, y a partir de
ahí acepta `Ping`/`Farewell`.
Wire format: frames length-prefixed (4 bytes LE) con cuerpo
`postcard`-codificado. Compacto, rápido y reversible.
Esto NO es la implementación WIT/WASM (que generaría wit-bindgen). Es la
implementación nativa Rust↔Rust que cubre el caso común antes de que los
módulos WASM consuman el mismo contrato vía ABI generada.
---
Parte de **chasqui** — ver [chasqui](../LEEME.md).
+11
View File
@@ -0,0 +1,11 @@
# card-handshake
`brahman-handshake` — the runtime Init↔module protocol over a Unix socket.
It implements the concrete version of `shared_wit/protocol.wit` (handshake +
lifecycle): a server living in the Init (or an Admin proxy) and clients that are the
Brahman modules. Every connection starts with a `Hello`.
---
Part of **chasqui** — see [chasqui](../README.md).
@@ -82,7 +82,7 @@ where
{
/// Constructor genérico sobre un stream ya abierto, **sin firma**.
/// Apto para path Unix (donde SO_PEERCRED del kernel ya autentica)
/// o tests in-memory. Para libp2p remoto usá
/// o tests in-memory. Para libp2p remoto usa
/// [`connect_with_stream_signed`](Self::connect_with_stream_signed) —
/// el server libp2p rechaza Hello sin firma.
pub async fn connect_with_stream(
@@ -0,0 +1,91 @@
//! Observación de la **salud del broker por polling**: diff entre snapshots de
//! matches tomados en ticks sucesivos.
//!
//! A diferencia de [`crate::messages::MatchEvent`] (push del server al consumer,
//! tipo de protocolo serializable), esto es la observación que arma un monitor
//! que **consulta** la `MatchList` cada N segundos y deriva qué apareció
//! (`Available`) y qué desapareció (`Lost`) entre ticks. Vivía atrapado en el
//! frontend `chasqui-broker-explorer-llimphi`; baja aquí —junto a `MatchList` y
//! `MatchEventKind`— para que cualquier monitor (UI, CLI) lo reuse (Regla 2).
use std::collections::HashSet;
use chasqui_broker::MatchStrategy;
use crate::messages::{MatchEventKind, MatchList, SessionId};
/// Clave estable de un match: `(sesión_consumer, flow_consumer, sesión_producer,
/// flow_producer)`. Identifica un match entre ticks para detectar altas y bajas.
pub type MatchKey = (SessionId, String, SessionId, String);
/// Una entrada del timeline de salud: un alta/baja observada al diferenciar dos
/// snapshots, con el momento en que el monitor la vio.
#[derive(Clone, Debug)]
pub struct TimelineEntry {
pub at: std::time::SystemTime,
pub kind: MatchEventKind,
pub consumer_label: String,
pub consumer_flow: String,
pub producer_label: String,
pub producer_flow: String,
pub via: MatchStrategy,
pub pinned: bool,
}
/// Diff puro entre snapshots de matches. Devuelve la lista de entries nuevas
/// (Available + Lost) en orden Available-primero, y el set actualizado de keys.
pub fn diff_matches(
last_keys: &HashSet<MatchKey>,
list: &MatchList,
) -> (Vec<TimelineEntry>, HashSet<MatchKey>) {
let now = std::time::SystemTime::now();
let current_keys: HashSet<MatchKey> = list
.matches
.iter()
.map(|m| {
(
m.consumer.session,
m.consumer.flow_name.clone(),
m.producer.session,
m.producer.flow_name.clone(),
)
})
.collect();
let mut entries = Vec::new();
for m in &list.matches {
let key = (
m.consumer.session,
m.consumer.flow_name.clone(),
m.producer.session,
m.producer.flow_name.clone(),
);
if !last_keys.contains(&key) {
entries.push(TimelineEntry {
at: now,
kind: MatchEventKind::Available,
consumer_label: m.consumer_label.clone(),
consumer_flow: m.consumer.flow_name.clone(),
producer_label: m.producer_label.clone(),
producer_flow: m.producer.flow_name.clone(),
via: m.via,
pinned: m.pinned,
});
}
}
for key in last_keys.iter() {
if !current_keys.contains(key) {
entries.push(TimelineEntry {
at: now,
kind: MatchEventKind::Lost,
consumer_label: String::new(),
consumer_flow: key.1.clone(),
producer_label: String::new(),
producer_flow: key.3.clone(),
via: MatchStrategy::Exact,
pinned: false,
});
}
}
(entries, current_keys)
}
@@ -131,7 +131,7 @@ pub struct SessionCert {
/// format libp2p protobuf.
pub session_pubkey: Vec<u8>,
/// Public key de la master identity, en format libp2p protobuf.
/// El verificador deriva el `master_peer_id` desde a.
/// El verificador deriva el `master_peer_id` desde aquí.
pub master_pubkey: Vec<u8>,
/// Expiración en milisegundos desde UNIX_EPOCH. Tras esto, el
/// cert no es válido y el nodo debe re-emitirse uno nuevo
@@ -18,6 +18,7 @@
#![warn(rust_2018_idioms)]
pub mod codec;
pub mod health;
pub mod identity;
pub mod messages;
pub mod server;
@@ -37,6 +37,8 @@ fn sample_card(label: &str) -> Card {
namespaces: NamespaceSet::default(),
rlimits: ResourceLimits::default(),
cpu_affinity: None,
run_as: None,
mounts: card_core::MountPlan::default(),
},
payload: Payload::Virtual,
supervision: Supervision::OneShot,
@@ -224,6 +226,8 @@ fn card_with_flows(label: &str, input: Vec<Flow>, output: Vec<Flow>) -> Card {
namespaces: NamespaceSet::default(),
rlimits: ResourceLimits::default(),
cpu_affinity: None,
run_as: None,
mounts: card_core::MountPlan::default(),
},
payload: Payload::Virtual,
supervision: Supervision::OneShot,
+22
View File
@@ -0,0 +1,22 @@
# card-sidecar
*Read this in English: [README.md](README.md).*
`brahman-sidecar` — boilerplate del cliente brahman extraído.
Cualquier módulo que quiera presentarse al Init brahman pero que tenga
su propio runtime (GPUI, current_thread tokio, std-thread loop, etc.)
puede llamar `spawn` con su `card_core::Card`. Eso arma un
thread aparte con un runtime tokio current_thread, conecta al Init,
y mantiene la sesión viva con pings periódicos.
Si el Init no está disponible, el thread loggea y termina — el módulo
sigue funcionando standalone.
Errores de conexión / ping se loggean vía `tracing::warn!`. Si quieres
capturar la salida del thread (por ejemplo para test), usa
`spawn_with_handle` que devuelve un `JoinHandle`.
---
Parte de **chasqui** — ver [chasqui](../LEEME.md).
+11
View File
@@ -0,0 +1,11 @@
# card-sidecar
`brahman-sidecar` — the brahman client's boilerplate, extracted.
Any module that wants to present itself to the brahman Init but has its own runtime
(a current_thread tokio, an std-thread loop, and so on) can call `spawn` with its
`card_core::Card`.
---
Part of **chasqui** — see [chasqui](../README.md).
@@ -3,7 +3,7 @@
//! sockets ni reimplementar el patrón a mano.
//!
//! Es la generalización de `discover_producer_socket` del CLI
//! `chasqui attract --remote`: declarás el `TypeRef` que querés
//! `chasqui attract --remote`: declaras el `TypeRef` que quieres
//! consumir y el broker te empuja un `MatchEvent::Available` con el
//! `producer_service_socket` del primer proveedor matched.
//!
@@ -18,7 +18,7 @@
//! `current_thread`.
//!
//! Quién elige al proveedor es el broker, no este módulo. Si el
//! broker tiene `priority_contexts` activo, podés cambiar de
//! broker tiene `priority_contexts` activo, puedes cambiar de
//! proveedor sin tocar el consumer; el matching dinámico se respeta.
use std::path::PathBuf;
@@ -70,8 +70,8 @@ pub enum ConsumerError {
}
/// Construye una Card mínima de consumer que declara un input flow
/// con el `TypeRef::Primitive { name }` solicitado. Usá esto para
/// el caso común; si necesitás algo más rico (output flows,
/// con el `TypeRef::Primitive { name }` solicitado. Usa esto para
/// el caso común; si necesitas algo más rico (output flows,
/// permissions, references) construí la Card a mano y pasala a
/// [`await_provider`] directamente.
pub fn build_consumer_card(
+3 -3
View File
@@ -9,8 +9,8 @@
//! Si el Init no está disponible, el thread loggea y termina — el módulo
//! sigue funcionando standalone.
//!
//! Errores de conexión / ping se loggean vía `tracing::warn!`. Si querés
//! capturar la salida del thread (por ejemplo para test), usá
//! Errores de conexión / ping se loggean vía `tracing::warn!`. Si quieres
//! capturar la salida del thread (por ejemplo para test), usa
//! [`spawn_with_handle`] que devuelve un `JoinHandle`.
#![forbid(unsafe_code)]
@@ -65,7 +65,7 @@ impl SidecarConfig {
}
}
/// Spawn fire-and-forget agnóstico. Para módulos conscientes usá
/// Spawn fire-and-forget agnóstico. Para módulos conscientes usa
/// [`spawn_conscious`] o [`spawn_with_handle`] con un `SidecarConfig`
/// personalizado.
pub fn spawn(card: Card) {
@@ -20,9 +20,19 @@ llimphi-widget-banner = { workspace = true }
llimphi-widget-stat-card = { workspace = true }
llimphi-widget-menubar = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
llimphi-widget-skeleton = { workspace = true }
llimphi-widget-empty = { workspace = true }
llimphi-widget-toast = { workspace = true }
llimphi-icons = { workspace = true }
llimphi-motion = { workspace = true }
app-bus = { workspace = true }
rimay-localize = { workspace = true }
[[bin]]
name = "chasqui-broker-explorer-llimphi"
path = "src/main.rs"
[dev-dependencies]
# Sólo para el example `chasqui_showreel` (volcado headless a PNG, sin winit).
pollster = { workspace = true }
png = { workspace = true }
@@ -0,0 +1,841 @@
//! **Showreel** de `chasqui` — la capa de red / broker P2P soberano sobre
//! Cards (brahman). NO es eye-candy abstracto: cada frame reconstruye el
//! **chrome real** del probe del broker (`chasqui-broker-explorer-llimphi`):
//! menubar + app-header con la línea de probe + banner de salud + tres
//! `stat_card` (Estado · Sesiones activas · Timeline de matches) + las filas
//! del timeline formateadas con `format_timeline_entry` REAL — todo con los
//! MISMOS widgets que pinta la app en producción (`menubar_view`,
//! `app_header`, `banner_view`, `stat_card_view`).
//!
//! El **estado** se deriva del tiempo normalizado `t∈[0,1]`: el chrome hace
//! slide-in; la salud del broker recorre DOWN → UP/NO PROVIDER → UP/PROVIDER;
//! las sesiones (peers) entran con stagger en la card de sesiones; y el
//! timeline de matches se llena fila a fila — consumidor ← productor
//! apareciendo en vivo, que es la imagen de "peers que se enganchan por flow".
//! Cierra con el wordmark **chasqui**.
//!
//! Render headless y determinista (sin reloj, sin runtime, sin winit): frame
//! `i` de `N` → `t = i/(N-1)` → View → layout (taffy + parley) → vello::Scene
//! → wgpu → PNG. Idéntico al eventloop.
//!
//! ```text
//! cargo run -p chasqui-broker-explorer-llimphi --example chasqui_showreel \
//! --release -- [out_dir] [n_frames] [W] [H]
//! ```
//! Defaults: `out_dir=showreel_frames_chasqui`, `n_frames=300`, `W=1600`, `H=900`.
#![allow(dead_code)]
use std::fs::{create_dir_all, File};
use std::io::BufWriter;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use app_bus::{AppMenu, Menu, MenuItem};
use card_handshake::health::TimelineEntry;
use card_handshake::messages::MatchEventKind;
use chasqui_broker::MatchStrategy;
use llimphi_motion::motion;
use llimphi_theme::Theme;
use llimphi_ui::llimphi_hal::{wgpu, Hal};
use llimphi_ui::llimphi_layout::taffy::{
self,
prelude::{length, percent, Dimension, FlexDirection, Size, Style},
style::Position,
AlignItems, Rect,
};
use llimphi_ui::llimphi_layout::LayoutTree;
use llimphi_ui::llimphi_raster::peniko::{self, Color};
use llimphi_ui::llimphi_raster::vello::kurbo::{Affine, BezPath, Circle, Point, Stroke};
use llimphi_ui::llimphi_raster::{vello, Renderer};
use llimphi_ui::llimphi_text::{draw_layout_brush_xf, measurement, Alignment, Typesetter};
use llimphi_ui::{measure_text_node, mount, paint, Mounted, PaintRect, View};
use llimphi_widget_app_header::{app_header, AppHeaderPalette};
use llimphi_widget_banner::{banner_view, BannerKind};
use llimphi_widget_menubar::{menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H};
use llimphi_widget_stat_card::{stat_card_view, StatCardPalette};
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// Msg fantasma: el showreel no despacha eventos, pero los widgets reales
/// exigen un Msg `Clone + 'static`.
#[derive(Clone)]
enum Msg {
Nada,
}
// ───────────────────────── utilidades ─────────────────────────
fn with_alpha(c: Color, a: f32) -> Color {
let [r, g, b, _] = c.components;
Color::new([r, g, b, a.clamp(0.0, 1.0)])
}
fn lerp(a: f64, b: f64, t: f64) -> f64 {
a + (b - a) * t
}
/// Reescala `t` desde el subintervalo `[lo,hi]` a `[0,1]`, clampado.
fn seg(t: f32, lo: f32, hi: f32) -> f32 {
((t - lo) / (hi - lo)).clamp(0.0, 1.0)
}
// ───────────────────────── skin ─────────────────────────
#[derive(Clone)]
struct Skin {
theme: Theme,
accent: Color,
bg: Color,
fg: Color,
fg_muted: Color,
// Acentos por estado del probe (espejo de la app real).
accent_up: Color,
accent_partial: Color,
accent_down: Color,
accent_pending: Color,
}
// ───────────────────────── datos sintéticos (forma real) ─────────────────────────
/// Una "sesión" (peer) registrada en el broker, con sus flows in/out — la
/// misma forma que `SessionEntry` produce en la card de sesiones real.
struct Peer {
label: &'static str,
inputs: &'static [&'static str],
outputs: &'static [&'static str],
conscious: bool,
}
const PEERS: &[Peer] = &[
Peer {
label: "chasqui-nous",
inputs: &["doc-ingest:json"],
outputs: &["monad-list:json", "event-log:tail"],
conscious: true,
},
Peer {
label: "pluma-app",
inputs: &["monad-list:json"],
outputs: &["doc-ingest:json"],
conscious: false,
},
Peer {
label: "nahual-shell",
inputs: &["monad-list:json", "thumb:png"],
outputs: &[],
conscious: false,
},
Peer {
label: "shuma-term",
inputs: &["event-log:tail"],
outputs: &["cmd-run:json"],
conscious: false,
},
Peer {
label: "khipu",
inputs: &[],
outputs: &["thumb:png"],
conscious: true,
},
];
/// Formatea una entrada de sesión igual que la app real (label · in/out · wit).
fn fmt_peer(p: &Peer) -> String {
format!(
"{} · in:[{}] out:[{}]{}",
p.label,
p.inputs.join(","),
p.outputs.join(","),
if p.conscious { " (wit)" } else { "" }
)
}
/// Matches que el broker va "descubriendo" — consumidor ← productor por flow.
/// `(consumer_label, flow, producer_label, flow, via, pinned)`.
struct MatchSeed {
consumer_label: &'static str,
flow: &'static str,
producer_label: &'static str,
via: MatchStrategy,
pinned: bool,
}
const MATCHES: &[MatchSeed] = &[
MatchSeed {
consumer_label: "pluma-app",
flow: "monad-list:json",
producer_label: "chasqui-nous",
via: MatchStrategy::Exact,
pinned: true,
},
MatchSeed {
consumer_label: "nahual-shell",
flow: "monad-list:json",
producer_label: "chasqui-nous",
via: MatchStrategy::Exact,
pinned: false,
},
MatchSeed {
consumer_label: "chasqui-nous",
flow: "doc-ingest:json",
producer_label: "pluma-app",
via: MatchStrategy::Exact,
pinned: false,
},
MatchSeed {
consumer_label: "nahual-shell",
flow: "thumb:png",
producer_label: "khipu",
via: MatchStrategy::Structural,
pinned: false,
},
MatchSeed {
consumer_label: "shuma-term",
flow: "event-log:tail",
producer_label: "chasqui-nous",
via: MatchStrategy::Exact,
pinned: false,
},
];
/// Convierte un seed en `TimelineEntry` real (kind=Available) con un `at`
/// determinista escalonado, para reusar el formateo idéntico al de la app.
fn timeline_entry(seed: &MatchSeed, idx: usize) -> TimelineEntry {
// Base fija (no usa el reloj real → determinista): 14:32:10 + idx*7s.
let base = UNIX_EPOCH + Duration::from_secs(52_330 + idx as u64 * 7);
TimelineEntry {
at: base,
kind: MatchEventKind::Available,
consumer_label: seed.consumer_label.to_string(),
consumer_flow: seed.flow.to_string(),
producer_label: seed.producer_label.to_string(),
producer_flow: seed.flow.to_string(),
via: seed.via,
pinned: seed.pinned,
}
}
/// Espejo EXACTO de `format_timeline_entry` del binario `src/main.rs` —
/// no podemos importarlo (es un binario sin lib), así que lo replicamos
/// carácter a carácter para que las filas se vean idénticas a la app.
fn format_timeline_entry(e: &TimelineEntry) -> String {
let secs_today = e
.at
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs() % 86_400)
.unwrap_or(0);
let h = secs_today / 3600;
let m = (secs_today % 3600) / 60;
let s = secs_today % 60;
let kind = match e.kind {
MatchEventKind::Available => "+",
MatchEventKind::Lost => "-",
};
let pinned = if e.pinned { " (pinned)" } else { "" };
match e.kind {
MatchEventKind::Available => format!(
"{:02}:{:02}:{:02} {} {}.{}{}.{} [{:?}]{}",
h,
m,
s,
kind,
e.consumer_label,
e.consumer_flow,
e.producer_label,
e.producer_flow,
e.via,
pinned,
),
MatchEventKind::Lost => format!(
"{:02}:{:02}:{:02} {} ?.{} ← ?.{} (lost)",
h, m, s, kind, e.consumer_flow, e.producer_flow,
),
}
}
// ───────────────────────── chrome real ─────────────────────────
/// Menú real del probe (espejo de `app_menu` del binario).
fn app_menu() -> AppMenu {
AppMenu::new()
.menu(
Menu::new("Archivo")
.item(MenuItem::new("Refrescar probe", "file.refresh").shortcut("Ctrl+R"))
.item(MenuItem::new("Limpiar timeline", "file.clear"))
.item(MenuItem::new("Salir", "file.quit").shortcut("Ctrl+Q").separated()),
)
.menu(
Menu::new("Ver")
.item(MenuItem::new("Reconectar", "view.reconnect"))
.item(MenuItem::new("Cambiar tema", "view.theme").separated()),
)
.menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
}
fn menubar(cw: f64, ch: f64, s: &Skin) -> View<Msg> {
let menu = app_menu();
menubar_view(&MenuBarSpec {
menu: &menu,
open: None,
theme: &s.theme,
viewport: (cw as f32, ch as f32),
height: MENU_H,
on_open: Arc::new(|_| Msg::Nada),
on_command: Arc::new(|_: &str| Msg::Nada),
})
}
/// Fase de salud del broker derivada de `t` — recorre los tres estados
/// reales que el probe reporta.
#[derive(Clone, Copy, PartialEq)]
enum Health {
Pending,
Down,
UpNoProvider,
UpProvider,
}
fn health_at(t: f32) -> Health {
if t < 0.20 {
Health::Pending
} else if t < 0.34 {
Health::Down
} else if t < 0.46 {
Health::UpNoProvider
} else {
Health::UpProvider
}
}
fn health_banner(h: Health) -> Option<View<Msg>> {
match h {
Health::Pending => None,
Health::Down => Some(banner_view::<Msg>(
BannerKind::Error,
"Broker DOWN — connect failed (init socket no escucha)".to_string(),
)),
Health::UpNoProvider => Some(banner_view::<Msg>(
BannerKind::Warning,
"Broker UP, sin provider para el flow".to_string(),
)),
Health::UpProvider => Some(banner_view::<Msg>(
BannerKind::Success,
"Broker UP, provider matcheado".to_string(),
)),
}
}
fn state_card_params(h: Health, s: &Skin) -> (Color, String, String) {
match h {
Health::Pending => (
s.accent_pending,
"PENDING".into(),
"esperando primer probe…".into(),
),
Health::Down => (
s.accent_down,
"DOWN".into(),
"connect failed: init socket no responde".into(),
),
Health::UpNoProvider => (
s.accent_partial,
"UP / NO PROVIDER".into(),
"broker reachable; sin productor para flow `monad-list:json`".into(),
),
Health::UpProvider => (
s.accent_up,
"UP / PROVIDER".into(),
"flow `monad-list:json` matcheado en chasqui-nous.sock".into(),
),
}
}
/// Fila del timeline (espejo de la fila real en `view()`): texto monoespaciado
/// con la entry formateada, la última resaltada como "recién llegada".
fn timeline_row(text: String, selected: bool, s: &Skin) -> View<Msg> {
let mut row = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(20.0_f32),
},
padding: Rect {
left: length(10.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.text_aligned(text, 12.0, s.theme.fg_text, Alignment::Start);
if selected {
row = row.fill(s.theme.bg_selected);
}
row
}
// ───────────────────────── overlays vector (cold-open + wordmark) ─────────────────────────
fn signature_path(cw: f64, ch: f64) -> BezPath {
let cx = cw / 2.0;
let cy = ch / 2.0;
let mut p = BezPath::new();
p.move_to((cx - 360.0, cy + 40.0));
p.curve_to(
(cx - 150.0, cy - 220.0),
(cx + 150.0, cy + 220.0),
(cx + 360.0, cy - 40.0),
);
p
}
fn trim_path(full: &BezPath, prog: f64) -> (BezPath, Point) {
use vello::kurbo::ParamCurve;
let prog = prog.clamp(0.0, 1.0);
let mut cubic = None;
let mut start = Point::ZERO;
for el in full.elements() {
match el {
vello::kurbo::PathEl::MoveTo(p) => start = *p,
vello::kurbo::PathEl::CurveTo(c1, c2, p) => {
cubic = Some(vello::kurbo::CubicBez::new(start, *c1, *c2, *p));
}
_ => {}
}
}
let mut out = BezPath::new();
let mut head = start;
if let Some(cb) = cubic {
out.move_to(cb.p0);
let steps = 96;
for i in 1..=steps {
let u = (i as f64 / steps as f64) * prog;
let pt = cb.eval(u);
out.line_to(pt);
head = pt;
}
}
(out, head)
}
fn draw_overlays(scene: &mut vello::Scene, ts: &mut Typesetter, t: f32, cw: f64, ch: f64, s: &Skin) {
// ── COLD OPEN (0–11%) ──────────────────────────────────────────
let b1 = seg(t, 0.0, 0.10);
let line_vis = 1.0 - seg(t, 0.10, 0.17);
if line_vis > 0.001 {
let path = signature_path(cw, ch);
let draw_on = motion::ease_out_cubic(seg(t, 0.01, 0.11)) as f64;
let (trimmed, head) = trim_path(&path, draw_on);
let line_col = with_alpha(s.accent, 0.9 * line_vis);
scene.stroke(&Stroke::new(2.0), Affine::IDENTITY, line_col, None, &trimmed);
let pop = motion::ease_out_back(b1);
let r = (4.0 + 7.0 * pop as f64).max(0.0);
let dot_a = (b1 * line_vis).clamp(0.0, 1.0);
scene.fill(
peniko::Fill::NonZero,
Affine::IDENTITY,
with_alpha(s.accent, 0.18 * dot_a),
None,
&Circle::new(head, r * 3.2),
);
scene.fill(
peniko::Fill::NonZero,
Affine::IDENTITY,
with_alpha(s.accent, dot_a),
None,
&Circle::new(head, r),
);
}
// ── WORDMARK (84100%) ─────────────────────────────────────────
let word_in = seg(t, 0.86, 0.96);
let word_a = motion::ease_out_cubic(word_in);
if word_a > 0.001 {
let size = 140.0_f32;
let layout = ts.layout(
"chasqui", size, None, Alignment::Start, 1.0, false, None, 800.0, false, false, 0.0, 0.0,
);
let m = measurement(&layout);
let rise = lerp(24.0, 0.0, word_a as f64);
let ox = (cw - m.width as f64) / 2.0;
let oy = (ch - m.height as f64) / 2.0 - 18.0 + rise;
let brush = peniko::Brush::Solid(with_alpha(s.fg, word_a));
draw_layout_brush_xf(scene, &layout, &brush, Affine::translate((ox, oy)));
let sub_a = motion::ease_out_cubic(seg(t, 0.90, 1.0));
if sub_a > 0.001 {
let ssz = 26.0_f32;
let sub = ts.layout(
"sovereign P2P networking, in Rust",
ssz,
None,
Alignment::Start,
1.0,
false,
None,
400.0,
false,
false, 0.0, 0.0,
);
let sm = measurement(&sub);
let dot_r = 6.0;
let block_w = sm.width as f64 + dot_r * 2.0 + 14.0;
let sx = (cw - block_w) / 2.0;
let sy = oy + m.height as f64 + 18.0;
scene.fill(
peniko::Fill::NonZero,
Affine::IDENTITY,
with_alpha(s.accent, sub_a),
None,
&Circle::new(Point::new(sx + dot_r, sy + ssz as f64 * 0.42), dot_r as f64),
);
let sbrush = peniko::Brush::Solid(with_alpha(s.fg_muted, sub_a));
draw_layout_brush_xf(
scene,
&sub,
&sbrush,
Affine::translate((sx + dot_r * 2.0 + 14.0, sy)),
);
}
}
// ── punto teal de firma (esquina inf-der) ───────
let corner_a = seg(t, 0.04, 0.12) * (1.0 - seg(t, 0.82, 0.88));
if corner_a > 0.001 {
let cx = cw - 54.0;
let cy = ch - 54.0;
scene.fill(
peniko::Fill::NonZero,
Affine::IDENTITY,
with_alpha(s.accent, 0.16 * corner_a),
None,
&Circle::new(Point::new(cx, cy), 18.0),
);
scene.fill(
peniko::Fill::NonZero,
Affine::IDENTITY,
with_alpha(s.accent, 0.9 * corner_a),
None,
&Circle::new(Point::new(cx, cy), 6.0),
);
}
}
// ───────────────────────── la escena por frame ─────────────────────────
fn build_view(t: f32, cw: f64, ch: f64, s: &Skin) -> View<Msg> {
// ── timeline de animación ──
// chrome slide-in (1122%): menubar + header.
let chrome = motion::ease_out_cubic(seg(t, 0.11, 0.22));
// salud del broker (derivada por umbrales).
let health = health_at(t);
// sesiones (peers) entran con stagger una vez que el broker está UP (3458%).
let peers_reveal = motion::ease_out_cubic(seg(t, 0.34, 0.58));
// matches del timeline aparecen fila a fila (4680%) — sólo con provider.
let matches_reveal = motion::ease_out_cubic(seg(t, 0.46, 0.80));
// fade del chrome antes del wordmark (8288%).
let chrome_fade = 1.0 - seg(t, 0.82, 0.88);
let mut children: Vec<View<Msg>> = Vec::new();
if chrome_fade > 0.001 {
let menubar = menubar(cw, ch, s);
// header con la línea de probe real.
let header_palette = AppHeaderPalette::from_theme(&s.theme);
let reload_ms = 4 + ((t * 90.0) as u32 % 9);
let header_text = format!(
"Probe: {}/init.sock · flow: broker-health/ping · reload {} ms",
"$XDG_RUNTIME_DIR", reload_ms
);
let header = app_header::<Msg>(header_text, vec![], &header_palette);
// ── cuerpo ──
let stat_palette = StatCardPalette::from_theme(&s.theme);
let mut body_children: Vec<View<Msg>> = Vec::new();
// banner permanente con la salud actual.
if let Some(b) = health_banner(health) {
body_children.push(b);
}
// card de Estado.
let (state_accent, state_value, state_descr) = state_card_params(health, s);
body_children.push(stat_card_view::<Msg>(
"Estado",
state_value,
&state_descr,
state_accent,
&[],
&stat_palette,
));
// card de Sesiones activas (peers) — los items entran con stagger.
let n_peers_up = matches!(
health,
Health::UpNoProvider | Health::UpProvider
)
.then(|| ((PEERS.len() as f32) * peers_reveal).ceil() as usize)
.unwrap_or(0)
.min(PEERS.len());
// Listamos hasta 3 peers (los "recientes") para dejar aire al
// timeline de matches debajo — el contador refleja el total real.
let shown = n_peers_up.min(3);
let peer_items: Vec<String> = PEERS[..shown].iter().map(fmt_peer).collect();
let sessions_value = if n_peers_up == 0 {
"".to_string()
} else {
n_peers_up.to_string()
};
let sessions_descr = if n_peers_up == 0 {
"lista no disponible (broker DOWN o pendiente)".to_string()
} else {
"labels visibles + flows in/out · (wit) = consciente".to_string()
};
body_children.push(stat_card_view::<Msg>(
"Sesiones activas",
sessions_value,
&sessions_descr,
s.accent_up,
&peer_items,
&stat_palette,
));
// card de Timeline de matches (cabecera contador) — sólo con provider.
let n_matches = if health == Health::UpProvider {
(((MATCHES.len()) as f32) * matches_reveal).ceil() as usize
} else {
0
}
.min(MATCHES.len());
let timeline_value = n_matches.to_string();
let timeline_descr = if n_matches == 0 {
"esperando primer match…".to_string()
} else {
"click selecciona · right-click = menú · cap 50 entries".to_string()
};
body_children.push(stat_card_view::<Msg>(
"Timeline de matches",
timeline_value,
&timeline_descr,
s.accent_partial,
&[],
&stat_palette,
));
// filas del timeline: consumidor ← productor, la última resaltada.
for i in 0..n_matches {
let entry = timeline_entry(&MATCHES[i], i);
let txt = format_timeline_entry(&entry);
let is_last = i + 1 == n_matches && matches_reveal < 1.0;
body_children.push(timeline_row(txt, is_last, s));
}
let body = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
flex_grow: 1.0,
padding: Rect {
left: length(16.0_f32),
right: length(16.0_f32),
top: length(12.0_f32),
bottom: length(16.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(8.0_f32),
},
..Default::default()
})
.fill(s.theme.bg_app)
.children(body_children);
// slide-in vertical del chrome completo.
let dy = lerp(-22.0, 0.0, chrome as f64);
let chrome_view = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(s.theme.bg_app)
.alpha((chrome * chrome_fade).clamp(0.0, 1.0))
.transform(Affine::translate((0.0, dy)))
.children(vec![menubar, header, body]);
children.push(chrome_view);
}
// overlay full-screen del vector (cold-open + wordmark).
let overlay = View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(0.0),
top: length(0.0),
right: length(0.0),
bottom: length(0.0),
},
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.paint_with({
let s = s.clone();
move |scene, ts, _rect: PaintRect| {
draw_overlays(scene, ts, t, cw, ch, &s);
}
});
children.push(overlay);
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
position: Position::Relative,
..Default::default()
})
.fill(s.bg)
.children(children)
}
fn main() {
let mut args = std::env::args().skip(1);
let out_dir = args.next().unwrap_or_else(|| "showreel_frames_chasqui".to_string());
let n: usize = args.next().and_then(|v| v.parse().ok()).unwrap_or(300);
let w: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(1600);
let h: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(900);
create_dir_all(&out_dir).expect("mkdir out_dir");
let theme = Theme::dark();
let accent = Color::from_rgba8(0x2B, 0xD9, 0xA6, 0xFF); // teal firma
let skin = Skin {
accent,
bg: theme.bg_app,
fg: theme.fg_text,
fg_muted: theme.fg_muted,
// mismos acentos por estado que la app real.
accent_up: Color::from_rgba8(0xa3, 0xbe, 0x8c, 0xff),
accent_partial: Color::from_rgba8(0xeb, 0xcb, 0x8b, 0xff),
accent_down: Color::from_rgba8(0xbf, 0x61, 0x6a, 0xff),
accent_pending: Color::from_rgba8(0x6a, 0x72, 0x80, 0xff),
theme,
};
let [br, bg, bb, _] = skin.bg.components;
let base = Color::from_rgba8((br * 255.0) as u8, (bg * 255.0) as u8, (bb * 255.0) as u8, 255);
// GPU una sola vez; reusar device/renderer/target para los N frames.
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("showreel-chasqui"),
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&wgpu::TextureViewDescriptor::default());
let mut ts = Typesetter::new();
let cw = w as f64;
let ch = h as f64;
for i in 0..n {
let t = if n <= 1 { 0.0 } else { i as f32 / (n as f32 - 1.0) };
let root = build_view(t, cw, ch, &skin);
let mut layout = LayoutTree::new();
let mounted: Mounted<Msg> = mount(&mut layout, root);
let computed = {
let tmap = &mounted.text_measures;
layout
.compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| {
match tmap.get(&nid) {
Some(tm) => measure_text_node(&mut ts, tm, known, avail),
None => taffy::Size::ZERO,
}
})
.expect("layout")
};
let mut scene = vello::Scene::new();
paint(&mut scene, &mounted, &computed, &mut ts, None, None);
renderer
.render_to_view(&hal, &scene, &view, w, h, base)
.expect("render_to_view");
let path = format!("{out_dir}/frame_{i:04}.png");
write_png(&hal, &target, &path, w, h);
if i % 30 == 0 || i == n - 1 {
eprintln!("showreel-chasqui: frame {}/{} (t={:.3})", i + 1, n, t);
}
}
eprintln!("showreel-chasqui: {n} frames en {out_dir}/ ({w}x{h})");
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str, w: u32, h: u32) {
let unpadded = (w * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * h as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(h),
},
},
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((w * h * 4) as usize);
for r in 0..h as usize {
let sidx = r * padded;
pixels.extend_from_slice(&data[sidx..sidx + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), w, h);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut wr = enc.write_header().unwrap();
wr.write_image_data(&pixels).unwrap();
}
@@ -20,13 +20,14 @@
//! - `BRAHMAN_BROKER_PROBE_TYPE` — type name del flow probe
//! (default `ping`).
//!
//! Usá un type name probable (ej. `monad-list:json`,
//! Usa un type name probable (ej. `monad-list:json`,
//! `event-log:tail`) para detectar productores específicos del
//! ecosistema.
#![forbid(unsafe_code)]
use std::collections::{HashSet, VecDeque};
use std::hash::{Hash, Hasher};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
@@ -56,9 +57,15 @@ use llimphi_widget_menubar::{
DEFAULT_HEIGHT as MENU_H,
};
use llimphi_widget_stat_card::{stat_card_view, StatCardPalette};
use llimphi_widget_skeleton::{skeleton_view, SkeletonPalette};
use llimphi_widget_empty::{empty_view, EmptyPalette};
use llimphi_widget_toast::{toast_stack_view, Toast};
use llimphi_icons::Icon;
#[cfg(test)]
use ulid::Ulid;
use app_bus::{AppMenu, Menu, MenuItem};
use rimay_localize::{t, t_args};
const POLL_INTERVAL: Duration = Duration::from_secs(5);
const PROBE_TIMEOUT: Duration = Duration::from_secs(1);
@@ -67,6 +74,18 @@ const PROBE_TIMEOUT: Duration = Duration::from_secs(1);
/// más viejo se descarta. 50 cubre ~4 minutos de actividad densa.
const TIMELINE_CAP: usize = 50;
/// Cuánto vive un toast efímero antes de auto-descartarse.
const TOAST_TTL: Duration = Duration::from_secs(4);
/// Hash estable de una cadena → `key` para animaciones implícitas
/// (la misma entry produce siempre la misma key entre rebuilds, así el
/// pop-in sólo anima la primera aparición).
fn key_of(s: &str) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
s.hash(&mut h);
h.finish()
}
#[derive(Clone, Debug)]
enum ProbeState {
Pending,
@@ -75,19 +94,10 @@ enum ProbeState {
UpWithProvider { flow: String, producer_socket: PathBuf },
}
#[derive(Clone, Debug)]
struct TimelineEntry {
at: std::time::SystemTime,
kind: card_handshake::messages::MatchEventKind,
consumer_label: String,
consumer_flow: String,
producer_label: String,
producer_flow: String,
via: chasqui_broker::MatchStrategy,
pinned: bool,
}
type MatchKey = (Ulid, String, Ulid, String);
// El diff de salud del broker (TimelineEntry/MatchKey/diff_matches) vive en el
// core agnóstico `card_handshake::health` (Regla 2): observar la diff de matches
// entre ticks es lógica reusable, no exclusiva de este frontend.
use card_handshake::health::{diff_matches, MatchKey, TimelineEntry};
struct Model {
theme: Theme,
@@ -113,6 +123,14 @@ struct Model {
/// Menú contextual sobre una entry: `(idx, x, y)` ancla en ventana.
/// `None` cerrado.
context_menu: Option<(usize, f32, f32)>,
/// Toasts efímeros vivos (errores del probe, confirmaciones de
/// acciones del usuario).
toasts: Vec<Toast>,
/// Id incremental para correlacionar toast ↔ `Msg` de expiración.
next_toast: u64,
/// Hay una cadena de `Msg::Shimmer` en vuelo (evita rearmar dos);
/// sólo corre mientras el probe está en estado `Pending`.
shimmering: bool,
}
#[derive(Clone)]
@@ -146,6 +164,12 @@ enum Msg {
ContextMenuOpen(f32, f32),
/// Limpia el timeline acumulado.
ClearTimeline,
/// Un toast cumplió su `duration`: se descarta del stack.
ToastExpire(u64),
/// Tick de animación del shimmer del skeleton — fuerza repaint
/// mientras el probe está `Pending`. Se auto-rearma sólo si sigue
/// cargando, así no queda un loop de repaint ocioso.
Shimmer,
}
struct Explorer;
@@ -163,6 +187,7 @@ impl App for Explorer {
}
fn init(handle: &Handle<Msg>) -> Model {
rimay_localize::init();
let flow = std::env::var("BRAHMAN_BROKER_PROBE_FLOW")
.unwrap_or_else(|_| "broker-health".to_string());
let type_name = std::env::var("BRAHMAN_BROKER_PROBE_TYPE")
@@ -171,7 +196,7 @@ impl App for Explorer {
handle.dispatch(Msg::Tick);
handle.spawn_periodic(POLL_INTERVAL, || Msg::Tick);
Model {
let mut m = Model {
theme: Theme::dark(),
socket_path: transport::default_socket_path(),
flow,
@@ -186,7 +211,14 @@ impl App for Explorer {
menu_anim: Tween::idle(1.0),
selected: None,
context_menu: None,
}
toasts: Vec::new(),
next_toast: 0,
shimmering: false,
};
// Mientras esperamos el primer probe (estado `Pending`) corre el
// shimmer del skeleton del timeline.
ensure_shimmer(&mut m, handle);
m
}
fn on_key(model: &Model, event: &KeyEvent) -> Option<Msg> {
@@ -217,7 +249,30 @@ impl App for Explorer {
handle.spawn(move || run_probe(flow, type_name));
}
Msg::ProbeResult { state, sessions, matches, elapsed_ms } => {
// Toast sólo en la transición de estado (no en cada tick de
// 5 s con el mismo estado), para no spamear.
let changed =
std::mem::discriminant(&m.state) != std::mem::discriminant(&state);
m.state = state;
if changed {
let toast = match &m.state {
ProbeState::Down { reason } => Some(Toast::error(
m.next_toast,
t_args("chasqui-broker-down", &[("reason", reason.clone().into())]),
TOAST_TTL,
)),
ProbeState::UpWithProvider { .. } => Some(Toast::success(
m.next_toast,
t("chasqui-toast-provider-matched"),
TOAST_TTL,
)),
_ => None,
};
if let Some(toast) = toast {
m.next_toast += 1;
push_toast(&mut m, handle, toast);
}
}
m.sessions = sessions;
if let Some(list) = matches {
m.diff_matches_into_timeline(&list);
@@ -268,6 +323,14 @@ impl App for Explorer {
}
Msg::CycleTheme => {
m.theme = Theme::next_after(m.theme.name);
let id = m.next_toast;
m.next_toast += 1;
let toast = Toast::info(
id,
t_args("chasqui-toast-theme", &[("name", m.theme.name.to_string().into())]),
TOAST_TTL,
);
push_toast(&mut m, handle, toast);
}
Msg::SelectEntry(i) => {
m.selected = Some(i);
@@ -284,8 +347,24 @@ impl App for Explorer {
m.timeline.clear();
m.selected = None;
m.context_menu = None;
let id = m.next_toast;
m.next_toast += 1;
push_toast(
&mut m,
handle,
Toast::info(id, t("chasqui-toast-timeline-cleared"), TOAST_TTL),
);
}
Msg::ToastExpire(id) => {
m.toasts.retain(|t| t.id != id);
}
Msg::Shimmer => {
// El thread durmió 50 ms; `ensure_shimmer` reabre la cadena
// sólo si seguimos en `Pending`.
m.shimmering = false;
}
}
ensure_shimmer(&mut m, handle);
m
}
@@ -319,15 +398,15 @@ impl App for Explorer {
ProbeState::Pending => {}
ProbeState::Down { reason } => body_children.push(banner_view::<Msg>(
BannerKind::Error,
format!("Broker DOWN — {reason}"),
t_args("chasqui-broker-down", &[("reason", reason.clone().into())]),
)),
ProbeState::UpNoProvider { .. } => body_children.push(banner_view::<Msg>(
BannerKind::Warning,
"Broker UP, sin provider para el flow".to_string(),
t("chasqui-banner-no-provider"),
)),
ProbeState::UpWithProvider { .. } => body_children.push(banner_view::<Msg>(
BannerKind::Success,
"Broker UP, provider matcheado".to_string(),
t("chasqui-banner-provider-matched"),
)),
}
@@ -339,8 +418,9 @@ impl App for Explorer {
accent_down,
accent_pending,
);
let label_state = t("chasqui-card-state");
body_children.push(stat_card_view::<Msg>(
"Estado",
&label_state,
state_value,
&state_descr,
state_accent,
@@ -375,14 +455,13 @@ impl App for Explorer {
.map(|l| l.entries.len().to_string())
.unwrap_or_else(|| "".into());
let sessions_descr = match &model.sessions {
None => "lista no disponible (broker DOWN o pendiente)".to_string(),
Some(l) if l.entries.is_empty() => {
"sin sesiones registradas en el broker".into()
}
Some(_) => "labels visibles + flows in/out · (wit) = consciente".into(),
None => t("chasqui-sessions-unavailable"),
Some(l) if l.entries.is_empty() => t("chasqui-sessions-empty"),
Some(_) => t("chasqui-sessions-some"),
};
let label_sessions = t("chasqui-card-sessions");
body_children.push(stat_card_view::<Msg>(
"Sesiones activas",
&label_sessions,
sessions_count_value,
&sessions_descr,
accent_up,
@@ -395,12 +474,13 @@ impl App for Explorer {
// right-click en la raíz abre el contextual).
let timeline_value = model.timeline.len().to_string();
let timeline_descr = if model.timeline.is_empty() {
"esperando primer match…".to_string()
t("chasqui-timeline-waiting")
} else {
"click selecciona · right-click = menú · cap 50 entries".to_string()
t("chasqui-timeline-help")
};
let label_timeline = t("chasqui-card-timeline");
body_children.push(stat_card_view::<Msg>(
"Timeline de matches",
&label_timeline,
timeline_value,
&timeline_descr,
accent_partial,
@@ -408,32 +488,78 @@ impl App for Explorer {
&stat_palette,
));
for (i, e) in model.timeline.iter().take(20).enumerate() {
let selected = model.selected == Some(i);
let mut row = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(18.0_f32),
},
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.text_aligned(
format_timeline_entry(e),
11.0,
theme.fg_text,
Alignment::Start,
)
.on_click(Msg::SelectEntry(i));
if selected {
row = row.fill(theme.bg_selected);
// Timeline tri-estado:
// 1) Pending y vacío → filas skeleton con shimmer (esperando 1er probe).
// 2) vacío pero ya sondeado → empty-state con orientación.
// 3) con entries → filas reales, cada una entra con pop-in la
// primera vez que aparece su key.
if model.timeline.is_empty() {
if matches!(model.state, ProbeState::Pending) {
let skel = SkeletonPalette::from_theme(theme);
for _ in 0..5 {
body_children.push(
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(18.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.radius(4.0)
.clip(true)
.children(vec![skeleton_view::<Msg>(&skel)]),
);
}
} else {
let empty_pal = EmptyPalette::from_theme(theme);
let empty_descr = t("chasqui-empty-descr");
body_children.push(
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(160.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.children(vec![empty_view::<Msg>(
Icon::Bell,
t("chasqui-empty-title"),
Some(empty_descr.as_str()),
&empty_pal,
)]),
);
}
} else {
for (i, e) in model.timeline.iter().take(20).enumerate() {
let selected = model.selected == Some(i);
let mut row = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(18.0_f32),
},
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.text_aligned(
format_timeline_entry(e),
11.0,
theme.fg_text,
Alignment::Start,
)
.on_click(Msg::SelectEntry(i))
.animated_pop_in(key_of(&format_timeline_entry(e)), motion::NORMAL);
if selected {
row = row.fill(theme.bg_selected);
}
body_children.push(row);
}
body_children.push(row);
}
let body = View::new(Style {
@@ -458,7 +584,7 @@ impl App for Explorer {
.fill(theme.bg_app)
.children(body_children);
View::new(Style {
let root = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
@@ -470,7 +596,26 @@ impl App for Explorer {
// Right-click en la raíz (origen 0,0 ⇒ local == ventana) abre el
// menú contextual sobre la entry seleccionada.
.on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
.children(vec![menubar, header, body])
.children(vec![menubar, header, body]);
// Overlay de toasts efímeros (bottom-right). Click en uno = descartarlo.
let now = Instant::now();
let alive: Vec<Toast> = model.toasts.iter().filter(|t| t.is_alive(now)).cloned().collect();
if alive.is_empty() {
root
} else {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.children(vec![
root,
toast_stack_view(&alive, viewport_of(model), Msg::ToastExpire),
])
}
}
fn view_overlay(model: &Model) -> Option<View<Msg>> {
@@ -480,13 +625,13 @@ impl App for Explorer {
.timeline
.get(idx)
.map(format_timeline_entry)
.unwrap_or_else(|| "Entry".to_string());
.unwrap_or_else(|| t("chasqui-entry-fallback"));
let viewport = viewport_of(model);
// Acciones reales del probe (sólo lectura): refrescar el
// probe y limpiar el timeline. No inventamos edición.
let items = vec![
ContextMenuItem::action("Refrescar probe"),
ContextMenuItem::action("Limpiar timeline"),
ContextMenuItem::action(t("chasqui-refresh-probe")),
ContextMenuItem::action(t("chasqui-clear-timeline")),
];
let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> =
Arc::new(move |i: usize| match i {
@@ -545,26 +690,57 @@ fn menubar_spec<'a>(
fn app_menu() -> AppMenu {
AppMenu::new()
.menu(
Menu::new("Archivo")
.item(MenuItem::new("Refrescar probe", "file.refresh").shortcut("Ctrl+R"))
.item(MenuItem::new("Limpiar timeline", "file.clear"))
.item(MenuItem::new("Salir", "file.quit").shortcut("Ctrl+Q").separated()),
Menu::new(t("chasqui-menu-file"))
.item(MenuItem::new(t("chasqui-refresh-probe"), "file.refresh").shortcut("Ctrl+R"))
.item(MenuItem::new(t("chasqui-clear-timeline"), "file.clear"))
.item(MenuItem::new(t("exit"), "file.quit").shortcut("Ctrl+Q").separated()),
)
.menu(
Menu::new("Ver")
.item(MenuItem::new("Reconectar", "view.reconnect"))
.item(MenuItem::new("Cambiar tema", "view.theme").separated()),
Menu::new(t("chasqui-menu-view"))
.item(MenuItem::new(t("chasqui-menu-reconnect"), "view.reconnect"))
.item(MenuItem::new(t("cycle-theme"), "view.theme").separated()),
)
.menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
.menu(Menu::new(t("help")).item(MenuItem::new(t("about"), "help.about")))
}
/// Empuja un toast al stack y programa su expiración (acción real del
/// usuario o evento del probe, no ruido de cada tick).
fn push_toast(m: &mut Model, handle: &Handle<Msg>, toast: Toast) {
let id = toast.id;
m.toasts.push(toast);
handle.spawn(move || {
std::thread::sleep(TOAST_TTL);
Msg::ToastExpire(id)
});
}
/// Arranca la cadena de ticks del shimmer si el probe sigue `Pending` y no
/// hay ya una corriendo. Se auto-detiene cuando el primer probe responde.
fn ensure_shimmer(m: &mut Model, handle: &Handle<Msg>) {
if m.shimmering || !matches!(m.state, ProbeState::Pending) {
return;
}
m.shimmering = true;
handle.spawn(move || {
std::thread::sleep(Duration::from_millis(50));
Msg::Shimmer
});
}
/// Traduce un command id del menú principal al `Msg`/efecto real.
fn handle_menu_command(model: Model, cmd: &str, handle: &Handle<Msg>) -> Model {
fn handle_menu_command(mut model: Model, cmd: &str, handle: &Handle<Msg>) -> Model {
match cmd {
// Reconectar == re-disparar el probe; el ProbeResult refresca
// estado/sesiones/matches.
"file.refresh" | "view.reconnect" => {
handle.dispatch(Msg::Tick);
let id = model.next_toast;
model.next_toast += 1;
push_toast(
&mut model,
handle,
Toast::info(id, t("chasqui-toast-refreshing"), TOAST_TTL),
);
model
}
"file.clear" => {
@@ -612,24 +788,27 @@ fn state_card_params(
ProbeState::Pending => (
accent_pending,
"PENDING".into(),
"esperando primer probe…".into(),
t("chasqui-state-pending"),
),
ProbeState::Down { reason } => (
accent_down,
"DOWN".into(),
format!("connect failed: {reason}"),
t_args("chasqui-state-down", &[("reason", reason.clone().into())]),
),
ProbeState::UpNoProvider { flow } => (
accent_partial,
"UP / NO PROVIDER".into(),
format!("broker reachable; sin productor para flow `{flow}`"),
t_args("chasqui-state-no-provider", &[("flow", flow.clone().into())]),
),
ProbeState::UpWithProvider { flow, producer_socket } => (
accent_up,
"UP / PROVIDER".into(),
format!(
"flow `{flow}` matcheado en producer socket: {}",
producer_socket.display()
t_args(
"chasqui-state-provider",
&[
("flow", flow.clone().into()),
("socket", producer_socket.display().to_string().into()),
],
),
),
}
@@ -667,66 +846,6 @@ fn run_probe(flow: String, type_name: String) -> Msg {
}
}
/// Diff puro entre snapshots de matches. Devuelve la lista de
/// entries nuevas (Available + Lost) en orden Available-primero, y
/// el set actualizado de keys.
fn diff_matches(
last_keys: &HashSet<MatchKey>,
list: &card_handshake::messages::MatchList,
) -> (Vec<TimelineEntry>, HashSet<MatchKey>) {
use card_handshake::messages::MatchEventKind;
let now = std::time::SystemTime::now();
let current_keys: HashSet<MatchKey> = list
.matches
.iter()
.map(|m| {
(
m.consumer.session,
m.consumer.flow_name.clone(),
m.producer.session,
m.producer.flow_name.clone(),
)
})
.collect();
let mut entries = Vec::new();
for m in &list.matches {
let key = (
m.consumer.session,
m.consumer.flow_name.clone(),
m.producer.session,
m.producer.flow_name.clone(),
);
if !last_keys.contains(&key) {
entries.push(TimelineEntry {
at: now,
kind: MatchEventKind::Available,
consumer_label: m.consumer_label.clone(),
consumer_flow: m.consumer.flow_name.clone(),
producer_label: m.producer_label.clone(),
producer_flow: m.producer.flow_name.clone(),
via: m.via,
pinned: m.pinned,
});
}
}
for key in last_keys.iter() {
if !current_keys.contains(key) {
entries.push(TimelineEntry {
at: now,
kind: MatchEventKind::Lost,
consumer_label: String::new(),
consumer_flow: key.1.clone(),
producer_label: String::new(),
producer_flow: key.3.clone(),
via: chasqui_broker::MatchStrategy::Exact,
pinned: false,
});
}
}
(entries, current_keys)
}
fn format_timeline_entry(e: &TimelineEntry) -> String {
use card_handshake::messages::MatchEventKind;
let secs_today = e
@@ -11,5 +11,4 @@ description = "Brahman — broker de tipos: empareja productores↔consumidores
[dependencies]
card-core = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
ulid = { workspace = true }
@@ -0,0 +1,154 @@
//! Cuerpo **intensional** de una Mónada: la regla que (re)deriva sus
//! miembros en cada scan, en vez de curarlos a mano.
//!
//! Una Mónada **extensional** lista sus miembros explícitamente (un
//! álbum: estas 40 fotos). Una Mónada **intensional** no guarda la
//! lista: guarda un *predicado* ([`MonadQuery`]) que el motor evalúa
//! contra el corpus para producir los miembros ("Fotos" = todo lo que
//! sea imagen, en cualquier parte). Es la misma idea que una
//! smart-playlist o un saved-search: la membresía es una consulta, no
//! una colección.
//!
//! Este crate sólo define el **tipo** de la regla (dato puro,
//! serializable). La **evaluación** —que necesita leer el corpus y, para
//! [`MonadQuery::Near`], los embeddings— vive en `chasqui-core::resolve`,
//! igual que el resto de la lógica de scan/cluster/attraction.
use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use crate::Lens;
/// Predicado que define los miembros de una Mónada intensional.
///
/// Es un álgebra chica y cerrada bajo composición ([`All`](Self::All) /
/// [`Any`](Self::Any) / [`Not`](Self::Not)), de modo que casos reales se
/// expresan combinando hojas simples:
///
/// - `"Fotos"` = `Lens { lens: Gallery }` (o `Extension` de los formatos
/// de imagen, si se prefiere por extensión).
/// - `"Código Rust grande"` = `All { of: [Extension{rs}, ...] }`.
/// - `"Cerca de este viaje"` = `Near { min_similarity }`, comparando el
/// embedding de cada archivo contra el `centroid` de la propia Mónada
/// (que vive en el manifiesto, no aquí — la query es agnóstica del
/// centroide concreto).
///
/// Las hojas [`Extension`](Self::Extension) y [`Lens`](Self::Lens) son
/// deterministas y baratas (no requieren embeddings); [`Near`](Self::Near)
/// es la hoja semántica que apoya en el modelo vectorial de `chasqui`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum MonadQuery {
/// Verdadero si la extensión del archivo (lowercase, sin punto) está
/// en el conjunto. `{"png","jpg","jpeg",...}` = "imágenes por formato".
Extension {
/// Extensiones aceptadas, ya normalizadas a lowercase sin punto.
exts: BTreeSet<String>,
},
/// Verdadero si el lente discernido del archivo coincide. Es el modo
/// natural de "todo lo que se ve con tal vista" (Gallery, Code, …).
Lens {
/// Lente exigido.
lens: Lens,
},
/// Verdadero si el embedding del archivo está a coseno ≥
/// `min_similarity` del centroide de la Mónada. La hoja semántica:
/// "lo que se parece a esto".
Near {
/// Umbral de similitud coseno en `[-1, 1]`. Típico ≈ 0.7.
min_similarity: f32,
},
/// Conjunción: el archivo entra si satisface **todas** las
/// subconsultas. `of` vacío ⇒ verdadero (neutro del AND).
All {
/// Subconsultas que deben cumplirse todas.
of: Vec<MonadQuery>,
},
/// Disyunción: el archivo entra si satisface **alguna**. `of` vacío
/// ⇒ falso (neutro del OR).
Any {
/// Subconsultas, basta una.
of: Vec<MonadQuery>,
},
/// Negación: invierte la subconsulta.
Not {
/// Subconsulta a negar.
inner: Box<MonadQuery>,
},
}
impl MonadQuery {
/// Atajo: una query de imágenes por las extensiones más comunes.
/// Útil para sembrar la Mónada intensional canónica "Fotos".
pub fn imagenes() -> Self {
MonadQuery::Lens { lens: Lens::Gallery }
}
/// Atajo: una query por un único formato de archivo.
pub fn extension(ext: impl Into<String>) -> Self {
let mut exts = BTreeSet::new();
exts.insert(ext.into().to_lowercase());
MonadQuery::Extension { exts }
}
/// `true` si la query es puramente léxica (Extension/composición de
/// Extension) y por lo tanto evaluable **sin** leer el contenido ni
/// los embeddings — sólo mirando la ruta/extensión. Le sirve al
/// motor para decidir si puede resolverla en frío.
pub fn es_lexica(&self) -> bool {
match self {
MonadQuery::Extension { .. } => true,
MonadQuery::Lens { .. } | MonadQuery::Near { .. } => false,
MonadQuery::All { of } | MonadQuery::Any { of } => of.iter().all(Self::es_lexica),
MonadQuery::Not { inner } => inner.es_lexica(),
}
}
/// `true` si la query usa la hoja semántica [`Near`](Self::Near) en
/// algún lugar — es decir, su evaluación necesita un centroide y los
/// embeddings del corpus.
pub fn usa_embeddings(&self) -> bool {
match self {
MonadQuery::Near { .. } => true,
MonadQuery::Extension { .. } | MonadQuery::Lens { .. } => false,
MonadQuery::All { of } | MonadQuery::Any { of } => of.iter().any(Self::usa_embeddings),
MonadQuery::Not { inner } => inner.usa_embeddings(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_json_con_tag_op() {
let q = MonadQuery::All {
of: vec![
MonadQuery::extension("rs"),
MonadQuery::Not {
inner: Box::new(MonadQuery::Near { min_similarity: 0.8 }),
},
],
};
let s = serde_json::to_string(&q).unwrap();
assert!(s.contains(r#""op":"all""#), "{s}");
let back: MonadQuery = serde_json::from_str(&s).unwrap();
assert_eq!(back, q);
}
#[test]
fn lexica_vs_semantica() {
assert!(MonadQuery::extension("png").es_lexica());
assert!(!MonadQuery::imagenes().es_lexica()); // Lens necesita discernir
assert!(!MonadQuery::Near { min_similarity: 0.7 }.es_lexica());
let mixta = MonadQuery::Any {
of: vec![MonadQuery::extension("png"), MonadQuery::Near { min_similarity: 0.7 }],
};
assert!(!mixta.es_lexica());
assert!(mixta.usa_embeddings());
assert!(!MonadQuery::extension("png").usa_embeddings());
}
}
+99 -3
View File
@@ -33,10 +33,20 @@ use ulid::Ulid;
// Re-export para consumidores
pub use ::ulid;
pub mod intension;
pub mod query;
pub use intension::MonadQuery;
/// Versión del esquema del manifiesto. Bump al cambiar el schema.
pub const MONAD_SCHEMA_VERSION: u16 = 1;
///
/// v2 (grafo de Mónadas): `MonadManifest` gana `submonads` (contención
/// DAG: una Mónada puede contener otras) y `query` (cuerpo intensional:
/// los miembros se derivan de un [`MonadQuery`] en vez de curarse a
/// mano). Ambos campos son aditivos con `serde default`, así que un
/// manifiesto v1 deserializa sin pérdida; `validate` acepta esquemas
/// **iguales o anteriores** (sólo rechaza los más nuevos que no entiende).
pub const MONAD_SCHEMA_VERSION: u16 = 2;
/// Identificador opaco de un archivo registrado en la DB.
pub type FileId = Ulid;
@@ -176,6 +186,26 @@ pub struct MonadManifest {
/// IDs de archivos miembros (incluye pins).
pub members: BTreeSet<FileId>,
/// **Sub-Mónadas contenidas** (contención DAG). Una Mónada puede
/// contener otras además de archivos: "Fotos" contiene el álbum
/// "Viaje", que contiene las fotos. Como una sub-Mónada se referencia
/// por su `MonadId`, la *misma* sub-Mónada puede aparecer bajo varios
/// padres — multi-pertenencia sin duplicar el objeto. Es el eje de
/// **contención**, ortogonal a `lineage` (que es *derivación*:
/// split/merge histórico, no quién-contiene-a-quién).
#[serde(default)]
pub submonads: BTreeSet<MonadId>,
/// Cuerpo **intensional**, si la Mónada es una consulta. `None` =
/// Mónada extensional (los `members` se curan a mano o por
/// clustering). `Some(q)` = los miembros se (re)derivan evaluando `q`
/// contra el corpus en cada scan; `members` queda como la
/// materialización cacheada de esa evaluación (más los `pins`, que el
/// usuario fija aunque la query no los capture). Ver
/// [`intension::MonadQuery`].
#[serde(default)]
pub query: Option<MonadQuery>,
/// Unix ms de creación de la Mónada.
pub created_at_ms: u64,
@@ -232,6 +262,8 @@ impl MonadManifest {
dominant_lens: Lens::default(),
pins: BTreeSet::new(),
members: BTreeSet::new(),
submonads: BTreeSet::new(),
query: None,
created_at_ms: now_ms,
updated_at_ms: now_ms,
extensions: BTreeMap::new(),
@@ -240,7 +272,11 @@ impl MonadManifest {
/// Validación semántica.
pub fn validate(&self) -> Result<(), MonadError> {
if self.schema_version != MONAD_SCHEMA_VERSION {
// Aceptamos esquemas iguales o anteriores (los campos nuevos son
// aditivos con default). Sólo rechazamos uno MÁS nuevo que el que
// este binario entiende: ahí sí podrían venir invariantes que no
// sabemos verificar.
if self.schema_version > MONAD_SCHEMA_VERSION {
return Err(MonadError::SchemaMismatch {
got: self.schema_version,
expected: MONAD_SCHEMA_VERSION,
@@ -255,7 +291,15 @@ impl MonadManifest {
if !(0.0..=1.0).contains(&self.entropy) {
return Err(MonadError::InvalidEntropy(self.entropy));
}
if self.members.is_empty() && self.pins.is_empty() {
// Una Mónada es no-vacía si tiene algo que mostrar o una regla que
// la define: archivos, pines, sub-Mónadas, o un cuerpo intensional
// (que puede resolver a cero miembros hoy y a muchos tras el
// próximo scan — sigue siendo una Mónada legítima).
if self.members.is_empty()
&& self.pins.is_empty()
&& self.submonads.is_empty()
&& self.query.is_none()
{
return Err(MonadError::Empty);
}
let actual = self.members.len() as u32;
@@ -412,6 +456,58 @@ mod tests {
assert_eq!(data.presentation_hint, "code");
}
#[test]
fn submonads_solo_es_no_vacia() {
// Una Mónada-contenedor que sólo agrupa otras Mónadas (sin
// archivos propios) es válida — es el caso de "Fotos" conteniendo
// álbumes.
let mut m = MonadManifest::new("Fotos");
m.submonads.insert(Ulid::new());
m.touch();
m.validate().expect("submonads-only debe validar");
}
#[test]
fn query_sola_es_no_vacia() {
// Una Mónada intensional recién creada puede no tener miembros
// materializados todavía y aun así ser válida: su cuerpo es la
// query.
let mut m = MonadManifest::new("Imágenes");
m.query = Some(MonadQuery::imagenes());
m.touch();
assert_eq!(m.cardinality, 0);
m.validate().expect("query-only debe validar");
}
#[test]
fn schema_anterior_se_acepta() {
// Un manifiesto de un esquema previo (v1) sigue siendo legible:
// validate sólo rechaza esquemas MÁS nuevos.
let mut m = MonadManifest::new("legacy");
m.members.insert(Ulid::new());
m.touch();
m.schema_version = 1;
m.validate().expect("v1 debe validar contra un binario v2");
m.schema_version = MONAD_SCHEMA_VERSION + 1;
assert!(matches!(m.validate(), Err(MonadError::SchemaMismatch { .. })));
}
#[test]
fn json_roundtrip_preserva_grafo() {
let mut m = MonadManifest::new("Fotos");
m.submonads.insert(Ulid::new());
m.submonads.insert(Ulid::new());
m.query = Some(MonadQuery::Any {
of: vec![MonadQuery::extension("png"), MonadQuery::extension("jpg")],
});
m.touch();
let s = m.to_json_pretty().unwrap();
let m2 = MonadManifest::from_json(&s).unwrap();
assert_eq!(m2.submonads, m.submonads);
assert_eq!(m2.query, m.query);
}
#[test]
fn json_roundtrip() {
let mut m = MonadManifest::new("test-monad");
+4 -3
View File
@@ -13,17 +13,18 @@ chasqui-card = { path = "../chasqui-card" }
chasqui-nous = { path = "../chasqui-nous" }
shuma-discern = { workspace = true }
card-core = { workspace = true }
card-handshake = { path = "../card-handshake" }
card-sidecar = { path = "../card-sidecar" }
blake3 = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sled = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
ulid = { workspace = true }
walkdir = "2"
notify = { workspace = true }
# El contrato del DAG navegable compartido (PLAN-GRAFO): el adaptador
# `grafo::GrafoMonadas` ve el grafo de contención de Mónadas como GrafoNavegable
# para el cockpit universal (visor de DAGs en nahual).
grafo-nav = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
@@ -118,7 +118,7 @@ fn cmd_scan(args: &[String]) -> Cmd {
min_files()
);
if db.monad_count() == 0 {
println!(" (ninguna Mónada — bajá NOUSER_MIN_FILES o apuntá a un dir con más archivos)");
println!(" (ninguna Mónada — baja NOUSER_MIN_FILES o apunta a un dir con más archivos)");
return Ok(());
}
println!();
+13 -4
View File
@@ -155,9 +155,13 @@ fn top_extensions(files: &[&FileEntry], n: usize) -> Vec<String> {
/// Elige el lente dominante según la extensión más frecuente, con
/// fallback a `shuma-discern` sobre el head del archivo más
/// representativo cuando la extensión no da hint claro (Lens::Grid).
fn pick_lens(files: &[&FileEntry]) -> Lens {
let dominant = top_extensions(files, 1).into_iter().next();
let by_ext = match dominant.as_deref() {
/// Mapea una extensión (lowercase, sin punto) a su lente determinista,
/// o [`Lens::Grid`] si la extensión no da hint claro. Determinista y sin
/// disco — la fuente de verdad del "lente por formato", compartida entre
/// el clustering ([`pick_lens`]) y la evaluación de queries intensionales
/// ([`crate::resolve`]).
pub fn lens_from_ext(ext: Option<&str>) -> Lens {
match ext {
Some("rs" | "py" | "ts" | "tsx" | "js" | "jsx" | "go" | "java" | "kt" | "c" | "cpp"
| "cc" | "h" | "hpp" | "rb" | "swift" | "zig") => Lens::Code,
Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp" | "tiff" | "heic") => {
@@ -166,7 +170,12 @@ fn pick_lens(files: &[&FileEntry]) -> Lens {
Some("md" | "markdown" | "rst" | "txt" | "org" | "tex") => Lens::Markdown,
Some("db" | "sqlite" | "sqlite3" | "csv" | "tsv" | "parquet") => Lens::Database,
_ => Lens::Grid,
};
}
}
fn pick_lens(files: &[&FileEntry]) -> Lens {
let dominant = top_extensions(files, 1).into_iter().next();
let by_ext = lens_from_ext(dominant.as_deref());
if by_ext != Lens::Grid {
return by_ext;
}
+13
View File
@@ -146,6 +146,19 @@ impl MonadDb {
self.monads.get(&id)
}
/// Elimina una Mónada del store (cache + persistencia). Devuelve el
/// manifiesto removido, o `None` si no existía. No toca a quién la
/// referenciaba como sub-Mónada — eso es responsabilidad de la capa
/// de edición (`crate::edit`), que mantiene la coherencia del grafo.
pub fn remove_monad(&mut self, id: MonadId) -> Option<MonadManifest> {
if let Some(db) = &self.persistence {
if let Ok(tree) = db.open_tree(TREE_MONADS) {
let _ = tree.remove(id.to_string().as_bytes());
}
}
self.monads.remove(&id)
}
pub fn monads(&self) -> impl Iterator<Item = &MonadManifest> + '_ {
self.monads.values()
}
+442
View File
@@ -0,0 +1,442 @@
//! Edición del **grafo de Mónadas**: las mutaciones que convierten a
//! nahual en un *file manager* (no un mero visor).
//!
//! "Para manejar mis datos los voy a querer modificar, y submonadizar": el
//! usuario edita la **organización** (el grafo), no necesariamente los
//! bytes de los archivos. Estas operaciones reorganizan Mónadas y su
//! pertenencia sin mover un solo archivo en disco — el archivo físico es
//! agnóstico a en cuántas Mónadas vive.
//!
//! Todas las mutaciones siguen el patrón **clonar→mutar→reinsertar** sobre
//! [`MonadDb`], para que la escritura pase por `insert_monad` y herede el
//! write-through a sled cuando el store es persistente.
//!
//! Invariantes que esta capa mantiene:
//! - El grafo de contención es un **DAG**: [`add_submonad`] /
//! [`submonadize`] rechazan cualquier arista que cierre un ciclo.
//! - Al cambiar los miembros de una Mónada, su `centroid` se recalcula
//! (vía [`crate::embed`]) para que la atracción y las queries `Near`
//! sigan siendo significativas.
//! - [`delete_monad`] y [`merge`] dejan el grafo coherente: ningún padre
//! queda apuntando a una sub-Mónada que ya no existe.
use chasqui_card::{FileId, Lens, MonadId, MonadManifest, MonadQuery};
use thiserror::Error;
use crate::db::MonadDb;
use crate::embed;
/// Error de una operación de edición del grafo.
#[derive(Debug, Error, PartialEq, Eq)]
pub enum EditError {
#[error("Mónada inexistente: {0}")]
NotFound(MonadId),
/// Agregar `child` bajo `parent` cerraría un ciclo, porque `parent` ya
/// es alcanzable desde `child` (o son la misma).
#[error("ciclo: {child} ya contiene (transitivamente) a {parent}")]
Cycle { parent: MonadId, child: MonadId },
}
/// `true` si `target` es alcanzable desde `from` bajando por sub-Mónadas
/// (incluye el caso `from == target`). Sirve para detectar ciclos antes de
/// agregar una arista de contención.
fn reachable(db: &MonadDb, from: MonadId, target: MonadId) -> bool {
if from == target {
return true;
}
let mut stack = vec![from];
let mut visto = std::collections::BTreeSet::new();
while let Some(id) = stack.pop() {
if !visto.insert(id) {
continue;
}
if let Some(m) = db.monad(id) {
for sub in &m.submonads {
if *sub == target {
return true;
}
stack.push(*sub);
}
}
}
false
}
/// Recalcula el centroide de una Mónada desde los embeddings de sus
/// miembros actuales. Sin miembros, el centroide queda vacío (y `Near`
/// deja de matchear, como corresponde).
fn recompute_centroid(db: &mut MonadDb, id: MonadId) {
let Some(m) = db.monad(id) else { return };
let mut m = m.clone();
let vecs: Vec<Vec<f32>> = m
.members
.iter()
.filter_map(|fid| db.file(*fid))
.map(|f| embed::embed(f).to_vec())
.collect();
m.centroid = embed::centroid(&vecs);
m.centroid_model = if m.centroid.is_empty() {
None
} else {
Some(embed::MODEL_ID.to_string())
};
m.touch();
db.insert_monad(m);
}
/// Crea una Mónada vacía con un label y un lente, y la inserta. Devuelve
/// su id. Queda sin miembros hasta que se le agreguen (o se le ponga una
/// query); recién ahí valida como no-vacía.
pub fn create_monad(db: &mut MonadDb, label: impl Into<String>, lens: Lens) -> MonadId {
let mut m = MonadManifest::new(label);
m.dominant_lens = lens;
m.touch();
let id = m.id;
db.insert_monad(m);
id
}
/// Renombra una Mónada.
pub fn rename(db: &mut MonadDb, id: MonadId, label: impl Into<String>) -> Result<(), EditError> {
let mut m = db.monad(id).ok_or(EditError::NotFound(id))?.clone();
m.label = label.into();
m.touch();
db.insert_monad(m);
Ok(())
}
/// Cambia el lente dominante (la "vista" que el shell elige para la
/// Mónada).
pub fn set_lens(db: &mut MonadDb, id: MonadId, lens: Lens) -> Result<(), EditError> {
let mut m = db.monad(id).ok_or(EditError::NotFound(id))?.clone();
m.dominant_lens = lens;
m.touch();
db.insert_monad(m);
Ok(())
}
/// Fija (o quita, con `None`) el cuerpo intensional. Convertir una Mónada
/// extensional en intensional no borra sus `members` curados: la query
/// *suma* (ver `resolve::effective_members`).
pub fn set_query(
db: &mut MonadDb,
id: MonadId,
query: Option<MonadQuery>,
) -> Result<(), EditError> {
let mut m = db.monad(id).ok_or(EditError::NotFound(id))?.clone();
m.query = query;
m.touch();
db.insert_monad(m);
Ok(())
}
/// Agrega un archivo como miembro curado de la Mónada y recalcula el
/// centroide.
pub fn add_member(db: &mut MonadDb, id: MonadId, file: FileId) -> Result<(), EditError> {
let mut m = db.monad(id).ok_or(EditError::NotFound(id))?.clone();
m.members.insert(file);
m.touch();
db.insert_monad(m);
recompute_centroid(db, id);
Ok(())
}
/// Quita un archivo de los miembros curados (y de los pines). Recalcula el
/// centroide.
pub fn remove_member(db: &mut MonadDb, id: MonadId, file: FileId) -> Result<(), EditError> {
let mut m = db.monad(id).ok_or(EditError::NotFound(id))?.clone();
m.members.remove(&file);
m.pins.remove(&file);
m.touch();
db.insert_monad(m);
recompute_centroid(db, id);
Ok(())
}
/// Agrega `child` como sub-Mónada de `parent`. Rechaza la arista si
/// cerraría un ciclo (si `parent` ya es alcanzable desde `child`).
pub fn add_submonad(db: &mut MonadDb, parent: MonadId, child: MonadId) -> Result<(), EditError> {
if db.monad(parent).is_none() {
return Err(EditError::NotFound(parent));
}
if db.monad(child).is_none() {
return Err(EditError::NotFound(child));
}
if reachable(db, child, parent) {
return Err(EditError::Cycle { parent, child });
}
let mut m = db.monad(parent).unwrap().clone();
m.submonads.insert(child);
m.touch();
db.insert_monad(m);
Ok(())
}
/// Quita la arista de contención `parent → child`. No borra la sub-Mónada,
/// sólo la desvincula de ese padre (puede seguir colgando de otros).
pub fn remove_submonad(
db: &mut MonadDb,
parent: MonadId,
child: MonadId,
) -> Result<(), EditError> {
let mut m = db.monad(parent).ok_or(EditError::NotFound(parent))?.clone();
m.submonads.remove(&child);
m.touch();
db.insert_monad(m);
Ok(())
}
/// **Submonadizar**: crea una nueva Mónada hija de `parent` y le *traslada*
/// la selección de archivos y sub-Mónadas indicada (la saca de `parent` y
/// la mete en la hija). Es la operación canónica de "tomar un puñado de
/// cosas dentro de una Mónada y agruparlas en una sub-Mónada propia".
///
/// La hija hereda el lente de `parent` por defecto; su centroide se calcula
/// de los archivos trasladados. Devuelve el id de la hija.
pub fn submonadize(
db: &mut MonadDb,
parent: MonadId,
label: impl Into<String>,
members: &[FileId],
submonads: &[MonadId],
) -> Result<MonadId, EditError> {
let padre = db.monad(parent).ok_or(EditError::NotFound(parent))?.clone();
// La hija nace con la selección y el lente del padre.
let mut hija = MonadManifest::new(label);
hija.dominant_lens = padre.dominant_lens;
hija.lineage = Some(parent);
for f in members {
hija.members.insert(*f);
}
for s in submonads {
hija.submonads.insert(*s);
}
hija.touch();
let hija_id = hija.id;
db.insert_monad(hija);
recompute_centroid(db, hija_id);
// El padre suelta la selección y adopta a la hija.
let mut padre = padre;
for f in members {
padre.members.remove(f);
padre.pins.remove(f);
}
for s in submonads {
padre.submonads.remove(s);
}
padre.submonads.insert(hija_id);
padre.touch();
db.insert_monad(padre);
recompute_centroid(db, parent);
Ok(hija_id)
}
/// **Fusionar** `from` en `into`: traslada todos los miembros y
/// sub-Mónadas de `from` a `into`, repunta a todo padre que apuntaba a
/// `from` para que ahora apunte a `into`, y borra `from`. El grafo queda
/// sin referencias colgadas.
pub fn merge(db: &mut MonadDb, into: MonadId, from: MonadId) -> Result<(), EditError> {
if into == from {
return Ok(());
}
let origen = db.monad(from).ok_or(EditError::NotFound(from))?.clone();
let mut destino = db.monad(into).ok_or(EditError::NotFound(into))?.clone();
for f in &origen.members {
destino.members.insert(*f);
}
for p in &origen.pins {
destino.pins.insert(*p);
}
for s in &origen.submonads {
if *s != into {
destino.submonads.insert(*s);
}
}
destino.submonads.remove(&from);
destino.touch();
db.insert_monad(destino);
// Repuntar padres de `from` → `into`.
let padres: Vec<MonadId> = db
.monads()
.filter(|m| m.submonads.contains(&from))
.map(|m| m.id)
.collect();
for pid in padres {
if pid == into {
continue;
}
let mut p = db.monad(pid).unwrap().clone();
p.submonads.remove(&from);
if pid != into {
p.submonads.insert(into);
}
p.touch();
db.insert_monad(p);
}
db.remove_monad(from);
recompute_centroid(db, into);
Ok(())
}
/// Borra una Mónada y la desvincula de todo padre que la contenía. Los
/// archivos y sub-Mónadas que contenía NO se borran — sólo se disuelve el
/// agrupamiento.
pub fn delete_monad(db: &mut MonadDb, id: MonadId) {
let padres: Vec<MonadId> = db
.monads()
.filter(|m| m.submonads.contains(&id))
.map(|m| m.id)
.collect();
for pid in padres {
if let Some(p) = db.monad(pid) {
let mut p = p.clone();
p.submonads.remove(&id);
p.touch();
db.insert_monad(p);
}
}
db.remove_monad(id);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::resolve;
use chasqui_card::FileEntry;
use std::path::PathBuf;
use ulid::Ulid;
fn mk(path: &str, ext: &str) -> FileEntry {
FileEntry {
id: FileId::from(Ulid::new()),
path: PathBuf::from(path),
content_hash: None,
size: 100,
mtime_ms: 1_700_000_000_000,
extension: Some(ext.into()),
}
}
fn db_con_archivos(n: usize) -> (MonadDb, Vec<FileId>) {
let mut db = MonadDb::new();
let mut ids = Vec::new();
for i in 0..n {
let f = mk(&format!("/proj/src/f{i}.rs"), "rs");
ids.push(f.id);
db.insert_file(f);
}
(db, ids)
}
#[test]
fn crear_agregar_y_recalcular_centroide() {
let (mut db, files) = db_con_archivos(2);
let m = create_monad(&mut db, "src", Lens::Code);
assert!(db.monad(m).unwrap().centroid.is_empty());
add_member(&mut db, m, files[0]).unwrap();
add_member(&mut db, m, files[1]).unwrap();
let mon = db.monad(m).unwrap();
assert_eq!(mon.cardinality, 2);
assert!(!mon.centroid.is_empty(), "el centroide se recalcula al agregar");
assert_eq!(mon.centroid_model.as_deref(), Some(embed::MODEL_ID));
remove_member(&mut db, m, files[0]).unwrap();
assert_eq!(db.monad(m).unwrap().cardinality, 1);
}
#[test]
fn add_submonad_rechaza_ciclo() {
let mut db = MonadDb::new();
let a = create_monad(&mut db, "A", Lens::Grid);
let b = create_monad(&mut db, "B", Lens::Grid);
add_submonad(&mut db, a, b).unwrap(); // A ⊃ B
// B ⊃ A cerraría el ciclo.
assert_eq!(add_submonad(&mut db, b, a), Err(EditError::Cycle { parent: b, child: a }));
// A ⊃ A también.
assert_eq!(add_submonad(&mut db, a, a), Err(EditError::Cycle { parent: a, child: a }));
}
#[test]
fn submonadizar_traslada_la_seleccion() {
let (mut db, files) = db_con_archivos(3);
let padre = create_monad(&mut db, "todo", Lens::Code);
for f in &files {
add_member(&mut db, padre, *f).unwrap();
}
assert_eq!(db.monad(padre).unwrap().cardinality, 3);
// Submonadizar dos de los tres archivos a una hija "sub".
let hija = submonadize(&mut db, padre, "sub", &files[0..2], &[]).unwrap();
// El padre queda con 1 archivo + la hija como sub-Mónada.
let p = db.monad(padre).unwrap();
assert_eq!(p.cardinality, 1, "el padre soltó 2 archivos");
assert!(p.submonads.contains(&hija));
// La hija tiene los 2 archivos y lineage al padre.
let h = db.monad(hija).unwrap();
assert_eq!(h.cardinality, 2);
assert_eq!(h.lineage, Some(padre));
// Los 3 archivos siguen alcanzables transitivamente desde el padre.
assert_eq!(resolve::transitive_files(&db, padre).len(), 3);
}
#[test]
fn fusionar_repunta_padres_y_borra_origen() {
let (mut db, files) = db_con_archivos(2);
let raiz = create_monad(&mut db, "raíz", Lens::Grid);
let a = create_monad(&mut db, "A", Lens::Code);
let b = create_monad(&mut db, "B", Lens::Code);
add_member(&mut db, a, files[0]).unwrap();
add_member(&mut db, b, files[1]).unwrap();
add_submonad(&mut db, raiz, a).unwrap();
add_submonad(&mut db, raiz, b).unwrap();
// Fusionar B en A: A se queda con ambos archivos, B desaparece, y
// raíz deja de apuntar a B.
merge(&mut db, a, b).unwrap();
assert!(db.monad(b).is_none(), "B se borró");
assert_eq!(db.monad(a).unwrap().cardinality, 2);
let raiz = db.monad(raiz).unwrap();
assert!(raiz.submonads.contains(&a));
assert!(!raiz.submonads.contains(&b), "raíz no apunta a una Mónada borrada");
}
#[test]
fn borrar_desvincula_de_padres() {
let mut db = MonadDb::new();
let raiz = create_monad(&mut db, "raíz", Lens::Grid);
let hija = create_monad(&mut db, "hija", Lens::Grid);
add_submonad(&mut db, raiz, hija).unwrap();
delete_monad(&mut db, hija);
assert!(db.monad(hija).is_none());
assert!(!db.monad(raiz).unwrap().submonads.contains(&hija), "sin referencia colgada");
}
#[test]
fn set_query_convierte_a_intensional_sin_perder_curados() {
let (mut db, files) = db_con_archivos(1);
let png = mk("/a/foto.png", "png");
let png_id = png.id;
db.insert_file(png);
let m = create_monad(&mut db, "mix", Lens::Grid);
add_member(&mut db, m, files[0]).unwrap(); // un .rs curado
set_query(&mut db, m, Some(MonadQuery::imagenes())).unwrap();
// effective_members = el .rs curado + el .png por la query.
let eff = resolve::effective_members(&db, m);
assert!(eff.contains(&files[0]));
assert!(eff.contains(&png_id));
assert_eq!(eff.len(), 2);
}
}
@@ -10,7 +10,7 @@
//! que `chasqui-nous` (mock/real ↔ chasqui-core), reutilizado.
//!
//! Threading: un thread dedicado, blocking I/O. No vale la pena traer
//! tokio a — la frecuencia esperada es muy baja (UI poll cada 2s)
//! tokio aquí — la frecuencia esperada es muy baja (UI poll cada 2s)
//! y el handler es trivial (lock db → snapshot → write).
use std::io::{BufRead, BufReader, Write};
+191
View File
@@ -0,0 +1,191 @@
//! Adaptador: el **grafo de contención de Mónadas** como [`GrafoNavegable`]
//! (PLAN-GRAFO). Es el DAG que nahual muestra en su file-manager: una Mónada
//! («Fotos») contiene sub-Mónadas («Viaje») y archivos; una misma sub-Mónada
//! puede colgar de varios padres (multi-pertenencia). El eje es `submonads`
//! (contención), no `lineage` (derivación histórica split/merge).
//!
//! Verbos del dominio (ver [`nahual_source_core::MonadGraphMut`]): **Saltar**
//! (abrir la Mónada) y, sobre una selección, **Cercenar** (disolver el
//! agrupamiento — `delete_monad`; no borra los archivos). No hay *Restablecer*
//! ni *Conciliar* por nodo: el merge de Mónadas (`merge_monads`) es entre dos y
//! no encaja en el menú de un solo nodo.
//!
//! `MonadId` es un `Ulid` (128 bits) y no cabe en `NodoId = u64`, así que el
//! adaptador mantiene la tabla `ids` para traducir de vuelta.
use grafo_nav::{GrafoNavegable, Nodo, NodoId, Rol, Verbo};
use chasqui_card::MonadId;
use crate::db::MonadDb;
/// Un **snapshot owned** del DAG de contención de Mónadas de una [`MonadDb`]. No
/// retiene el borrow de la DB (copia label/entropy/aristas), así puede cruzar la
/// frontera `dyn Source` de nahual —construido dentro de `with_db(...)` y
/// devuelto— sin pelear con el `RwLock`.
pub struct GrafoMonadas {
/// `(id, label, entropy)` por nodo, en orden de la DB (índice = `NodoId`).
nodos: Vec<(MonadId, String, f32)>,
/// Aristas `(contenedora, contenida)` por `MonadId`.
aristas: Vec<(MonadId, MonadId)>,
/// Mónada en foco (se pinta [`Rol::Activo`]).
activo: Option<MonadId>,
}
impl GrafoMonadas {
/// Toma el snapshot desde la DB: nodos + aristas de contención (`submonads`
/// hacia Mónadas presentes).
pub fn from_db(db: &MonadDb) -> Self {
let nodos: Vec<(MonadId, String, f32)> = db
.monads()
.map(|m| (m.id, m.label.clone(), m.entropy))
.collect();
let presentes = alloc_set(&nodos);
let mut aristas = Vec::new();
for m in db.monads() {
for sub in &m.submonads {
if presentes.contains(sub) {
aristas.push((m.id, *sub));
}
}
}
Self { nodos, aristas, activo: None }
}
/// Marca la Mónada en foco (se pinta [`Rol::Activo`]).
pub fn con_activo(mut self, activo: Option<MonadId>) -> Self {
self.activo = activo;
self
}
/// Traduce el id compacto del visor al `MonadId` real (para abrir/seleccionar
/// la Mónada al clickear un nodo).
pub fn monad_id_de(&self, id: NodoId) -> Option<MonadId> {
self.nodos.get(id as usize).map(|(m, _, _)| *m)
}
/// Traduce un `MonadId` al id compacto del visor.
pub fn id_de(&self, m: &MonadId) -> Option<NodoId> {
self.nodos.iter().position(|(x, _, _)| x == m).map(|i| i as NodoId)
}
}
impl GrafoNavegable for GrafoMonadas {
fn nodos(&self) -> Vec<Nodo> {
self.nodos
.iter()
.enumerate()
.map(|(i, (mid, label, entropy))| {
let rol = if self.activo == Some(*mid) {
Rol::Activo
} else if *entropy > 0.66 {
// Mónada dispersa: el manifiesto sugiere bifurcarla.
Rol::Stale
} else {
Rol::Normal
};
let etiqueta = if label.is_empty() {
String::from("(sin nombre)")
} else {
label.clone()
};
Nodo::new(i as NodoId, etiqueta, rol)
})
.collect()
}
fn aristas(&self) -> Vec<(NodoId, NodoId)> {
// Contención: `(contenedora → contenida)` = `(ancestro → descendiente)`.
self.aristas
.iter()
.filter_map(|(f, t)| Some((self.id_de(f)?, self.id_de(t)?)))
.collect()
}
fn acciones(&self, seleccion: Option<NodoId>) -> Vec<Verbo> {
let mut v = vec![Verbo::Saltar];
if seleccion.is_some() {
// Cercenar = disolver el agrupamiento (delete_monad); no toca archivos.
v.push(Verbo::Cercenar);
}
v
}
}
/// Conjunto de los `MonadId` presentes en el snapshot (para descartar aristas a
/// sub-Mónadas ausentes).
fn alloc_set(nodos: &[(MonadId, String, f32)]) -> std::collections::BTreeSet<MonadId> {
nodos.iter().map(|(id, _, _)| *id).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use chasqui_card::MonadManifest;
/// DB en contención: raíz «Fotos» contiene «Viaje» y «Familia»; «Viaje»
/// contiene «Cusco». Devuelve (db, id_fotos, id_viaje, id_cusco).
fn db_contencion() -> (MonadDb, MonadId, MonadId, MonadId) {
let mut cusco = MonadManifest::new("Cusco");
let mut viaje = MonadManifest::new("Viaje");
let mut familia = MonadManifest::new("Familia");
let mut fotos = MonadManifest::new("Fotos");
viaje.submonads.insert(cusco.id);
fotos.submonads.insert(viaje.id);
fotos.submonads.insert(familia.id);
// Marcar una dispersa para el test de rol.
familia.entropy = 0.9;
let (fid, vid, cid) = (fotos.id, viaje.id, cusco.id);
let mut db = MonadDb::new();
for m in [cusco, viaje, familia, fotos] {
db.insert_monad(m);
}
(db, fid, vid, cid)
}
#[test]
fn estructura_de_contencion() {
let (db, fotos, viaje, cusco) = db_contencion();
let g = GrafoMonadas::from_db(&db);
assert_eq!(g.nodos().len(), 4);
// Aristas: fotos→viaje, fotos→familia, viaje→cusco = 3.
assert_eq!(g.aristas().len(), 3);
// «Fotos» es raíz (nadie la contiene); «Cusco»/«Familia» son cabezas.
let raices: Vec<MonadId> =
g.raices().iter().filter_map(|id| g.monad_id_de(*id)).collect();
assert_eq!(raices, vec![fotos]);
// El cono descendente de «Fotos» es todo (4).
let cono = g.cono_descendente(g.id_de(&fotos).unwrap());
assert_eq!(cono.len(), 4);
// «Cusco» es una hoja: su cono descendente es sólo él.
assert_eq!(g.cono_descendente(g.id_de(&cusco).unwrap()).len(), 1);
assert!(g.cabezas().contains(&g.id_de(&cusco).unwrap()));
let _ = viaje;
}
#[test]
fn rol_activo_y_dispersa() {
let (db, fotos, _viaje, _cusco) = db_contencion();
let g = GrafoMonadas::from_db(&db).con_activo(Some(fotos));
let nodos = g.nodos();
let rol = |mid: &MonadId| {
let nid = g.id_de(mid).unwrap();
nodos.iter().find(|n| n.id == nid).unwrap().rol
};
assert_eq!(rol(&fotos), Rol::Activo, "el foco gana");
// «Familia» tiene entropy 0.9 → Stale (sugerencia de bifurcar).
assert!(nodos.iter().any(|n| n.rol == Rol::Stale));
}
#[test]
fn verbos_saltar_y_cercenar() {
let (db, _f, _v, _c) = db_contencion();
let g = GrafoMonadas::from_db(&db);
assert_eq!(g.acciones(None), vec![Verbo::Saltar]);
let sel = g.acciones(Some(0));
assert!(sel.contains(&Verbo::Saltar));
assert!(sel.contains(&Verbo::Cercenar));
assert!(!sel.contains(&Verbo::Conciliar));
assert!(!sel.contains(&Verbo::Restablecer));
}
}
+4
View File
@@ -27,8 +27,12 @@
pub mod cluster;
pub mod db;
pub mod edit;
pub mod embed;
pub mod engine_socket;
pub mod grafo;
pub mod resolve;
pub mod scanner;
pub use chasqui_card::*;
pub use grafo::GrafoMonadas;
@@ -0,0 +1,303 @@
//! Resolución del **grafo de Mónadas**: contención (sub-Mónadas) +
//! cuerpo intensional (queries).
//!
//! El modelo (`chasqui-card`) ahora describe un DAG: una Mónada contiene
//! archivos (`members`) y/o otras Mónadas (`submonads`), y puede definir
//! su membresía por una regla ([`MonadQuery`]) en vez de a mano. Este
//! módulo es la lógica que *recorre* ese grafo:
//!
//! - [`resolve_query`] evalúa un predicado contra un corpus de archivos.
//! - [`effective_members`] da los archivos directos de una Mónada
//! (curados + intensional + pines).
//! - [`transitive_files`] baja por las sub-Mónadas (guardando ciclos y
//! diamantes) y junta todos los archivos alcanzables.
//! - [`child_monads`] resuelve las sub-Mónadas a sus manifiestos.
//!
//! Determinista y sin red. La hoja semántica [`MonadQuery::Near`] usa los
//! pseudo-embeddings de [`crate::embed`]; el resto es puramente léxico.
use std::collections::BTreeSet;
use chasqui_card::{FileEntry, FileId, MonadId, MonadManifest, MonadQuery};
use crate::cluster::lens_from_ext;
use crate::db::MonadDb;
use crate::embed;
/// Evalúa una [`MonadQuery`] contra un corpus y devuelve los IDs de los
/// archivos que la satisfacen.
///
/// `centroid` es el centroide de la Mónada dueña de la query — lo que la
/// hoja [`MonadQuery::Near`] usa como referencia de "cerca de esto". Si
/// la query no usa `Near`, `centroid` se ignora (pasa `&[]`).
pub fn resolve_query<'a, I>(query: &MonadQuery, files: I, centroid: &[f32]) -> BTreeSet<FileId>
where
I: IntoIterator<Item = &'a FileEntry>,
{
files
.into_iter()
.filter(|f| matches(query, f, centroid))
.map(|f| f.id)
.collect()
}
/// Decide si un archivo satisface la query. Recursivo sobre el álgebra.
fn matches(query: &MonadQuery, file: &FileEntry, centroid: &[f32]) -> bool {
match query {
MonadQuery::Extension { exts } => file
.extension
.as_deref()
.is_some_and(|e| exts.contains(e)),
// Lente determinista por extensión. Refinamiento futuro: discernir
// por contenido (shuma-discern) cuando la extensión miente; hoy nos
// quedamos en el mapeo barato y sin disco, coherente con el lente
// que el clustering asigna.
MonadQuery::Lens { lens } => lens_from_ext(file.extension.as_deref()) == *lens,
MonadQuery::Near { min_similarity } => {
if centroid.is_empty() {
return false;
}
// Recalcula el embedding por nodo Near; las queries reales
// tienen ≤1, así que no vale la pena cachear todavía.
let v = embed::embed(file);
embed::cosine_similarity(&v, centroid) >= *min_similarity
}
MonadQuery::All { of } => of.iter().all(|sub| matches(sub, file, centroid)),
MonadQuery::Any { of } => of.iter().any(|sub| matches(sub, file, centroid)),
MonadQuery::Not { inner } => !matches(inner, file, centroid),
}
}
/// Archivos **directos** de una Mónada: la unión de sus miembros curados,
/// los miembros derivados de su cuerpo intensional (si tiene `query`), y
/// sus pines. No baja por las sub-Mónadas (para eso, [`transitive_files`]).
///
/// Mónada inexistente ⇒ conjunto vacío.
pub fn effective_members(db: &MonadDb, id: MonadId) -> BTreeSet<FileId> {
let Some(m) = db.monad(id) else {
return BTreeSet::new();
};
let mut out: BTreeSet<FileId> = m.members.iter().copied().collect();
out.extend(m.pins.iter().copied());
if let Some(query) = &m.query {
out.extend(resolve_query(query, db.files(), &m.centroid));
}
out
}
/// Sub-Mónadas directas de una Mónada, resueltas a sus manifiestos.
/// Skipea silenciosamente IDs colgados (sub-Mónada borrada).
pub fn child_monads(db: &MonadDb, id: MonadId) -> Vec<&MonadManifest> {
match db.monad(id) {
Some(m) => m.submonads.iter().filter_map(|sid| db.monad(*sid)).collect(),
None => Vec::new(),
}
}
/// Todos los archivos alcanzables desde una Mónada bajando por el DAG de
/// sub-Mónadas. Cuenta cada archivo una sola vez aunque sea alcanzable por
/// varios caminos (es un `BTreeSet`), y termina aunque el grafo tenga
/// ciclos o diamantes gracias al set de visitados.
pub fn transitive_files(db: &MonadDb, root: MonadId) -> BTreeSet<FileId> {
let mut visited: BTreeSet<MonadId> = BTreeSet::new();
let mut files: BTreeSet<FileId> = BTreeSet::new();
collect_files(db, root, &mut visited, &mut files);
files
}
fn collect_files(
db: &MonadDb,
id: MonadId,
visited: &mut BTreeSet<MonadId>,
files: &mut BTreeSet<FileId>,
) {
// Guarda de ciclos/diamantes: cada Mónada se visita una vez.
if !visited.insert(id) {
return;
}
let Some(m) = db.monad(id) else {
return;
};
files.extend(effective_members(db, id));
for sub in &m.submonads {
collect_files(db, *sub, visited, files);
}
}
#[cfg(test)]
mod tests {
use super::*;
use chasqui_card::{FileId, Lens};
use std::collections::BTreeSet;
use std::path::PathBuf;
use ulid::Ulid;
fn mk(path: &str, ext: Option<&str>, size: u64) -> FileEntry {
FileEntry {
id: FileId::from(Ulid::new()),
path: PathBuf::from(path),
content_hash: None,
size,
mtime_ms: 1_700_000_000_000,
extension: ext.map(String::from),
}
}
fn exts(list: &[&str]) -> MonadQuery {
MonadQuery::Extension {
exts: list.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn query_extension_selecciona_por_formato() {
let corpus = vec![
mk("/a/1.png", Some("png"), 100),
mk("/a/2.jpg", Some("jpg"), 100),
mk("/a/3.rs", Some("rs"), 100),
];
let hit = resolve_query(&exts(&["png", "jpg"]), &corpus, &[]);
assert_eq!(hit.len(), 2);
assert!(hit.contains(&corpus[0].id));
assert!(hit.contains(&corpus[1].id));
assert!(!hit.contains(&corpus[2].id));
}
#[test]
fn query_lens_gallery_capta_imagenes() {
let corpus = vec![
mk("/a/1.png", Some("png"), 100),
mk("/a/2.heic", Some("heic"), 100),
mk("/a/3.rs", Some("rs"), 100),
];
let hit = resolve_query(&MonadQuery::imagenes(), &corpus, &[]);
assert_eq!(hit.len(), 2, "png + heic son Gallery, rs no");
}
#[test]
fn query_not_y_all_componen() {
let corpus = vec![
mk("/a/1.png", Some("png"), 100),
mk("/a/2.rs", Some("rs"), 100),
];
// "imágenes que NO sean png" → vacío (sólo hay png entre imágenes).
let q = MonadQuery::All {
of: vec![
MonadQuery::imagenes(),
MonadQuery::Not { inner: Box::new(exts(&["png"])) },
],
};
assert!(resolve_query(&q, &corpus, &[]).is_empty());
}
#[test]
fn query_near_usa_centroide() {
// Centroide de un cluster de .rs en /proj/src.
let rust = vec![
embed::embed(&mk("/proj/src/a.rs", Some("rs"), 1000)).to_vec(),
embed::embed(&mk("/proj/src/b.rs", Some("rs"), 1100)).to_vec(),
];
let centroid = embed::centroid(&rust);
let corpus = vec![
mk("/proj/src/c.rs", Some("rs"), 1200), // cerca
mk("/photos/x.jpg", Some("jpg"), 5_000_000), // lejos
];
let hit = resolve_query(&MonadQuery::Near { min_similarity: 0.7 }, &corpus, &centroid);
assert!(hit.contains(&corpus[0].id), "el .rs debe caer cerca del centroide");
assert!(!hit.contains(&corpus[1].id), "la foto debe quedar lejos");
}
#[test]
fn near_sin_centroide_no_matchea() {
let corpus = vec![mk("/a/x.rs", Some("rs"), 100)];
let hit = resolve_query(&MonadQuery::Near { min_similarity: 0.0 }, &corpus, &[]);
assert!(hit.is_empty(), "sin centroide, Near no puede decidir");
}
#[test]
fn effective_members_une_curados_y_query() {
let mut db = MonadDb::new();
let png = mk("/a/1.png", Some("png"), 100);
let rs = mk("/a/2.rs", Some("rs"), 100);
let png_id = png.id;
let rs_id = rs.id;
db.insert_file(png.clone());
db.insert_file(rs.clone());
// Mónada intensional "imágenes" + un .rs fijado a mano como miembro.
let mut m = MonadManifest::new("mixta");
m.query = Some(MonadQuery::imagenes());
m.members.insert(rs_id);
m.touch();
let mid = m.id;
db.insert_monad(m);
let eff = effective_members(&db, mid);
assert!(eff.contains(&png_id), "el png entra por la query");
assert!(eff.contains(&rs_id), "el rs entra por miembro curado");
assert_eq!(eff.len(), 2);
}
#[test]
fn transitive_baja_por_submonadas_y_dedup() {
let mut db = MonadDb::new();
let f1 = mk("/album/1.jpg", Some("jpg"), 100);
let f2 = mk("/album/2.jpg", Some("jpg"), 100);
let f1_id = f1.id;
let f2_id = f2.id;
db.insert_file(f1);
db.insert_file(f2);
// Álbum extensional con dos fotos.
let mut album = MonadManifest::new("Viaje");
album.members.insert(f1_id);
album.members.insert(f2_id);
album.touch();
let album_id = album.id;
db.insert_monad(album);
// "Fotos" contiene al álbum (sub-Mónada) y además una de las fotos
// directamente — el mismo archivo alcanzable por dos caminos.
let mut fotos = MonadManifest::new("Fotos");
fotos.submonads.insert(album_id);
fotos.members.insert(f1_id);
fotos.touch();
let fotos_id = fotos.id;
db.insert_monad(fotos);
let all = transitive_files(&db, fotos_id);
assert_eq!(all.len(), 2, "f1 contado una sola vez pese a dos caminos");
assert!(all.contains(&f1_id) && all.contains(&f2_id));
let kids = child_monads(&db, fotos_id);
assert_eq!(kids.len(), 1);
assert_eq!(kids[0].label, "Viaje");
}
#[test]
fn transitive_termina_con_ciclo() {
let mut db = MonadDb::new();
let f = mk("/x/a.rs", Some("rs"), 100);
let f_id = f.id;
db.insert_file(f);
let mut a = MonadManifest::new("A");
a.members.insert(f_id);
let a_id = a.id;
let mut b = MonadManifest::new("B");
b.members.insert(f_id);
let b_id = b.id;
// Ciclo: A contiene B, B contiene A.
a.submonads.insert(b_id);
b.submonads.insert(a_id);
a.touch();
b.touch();
db.insert_monad(a);
db.insert_monad(b);
// No debe colgarse; junta el único archivo.
let all = transitive_files(&db, a_id);
assert_eq!(all, BTreeSet::from([f_id]));
}
}
@@ -8,7 +8,6 @@ publish.workspace = true
description = "Explorador Llimphi de Mónadas: panel que descubre al daemon chasqui vía broker brahman y consulta sus Mónadas dinámicamente. Reemplazo del `chasqui-explorer` GPUI; el discovery (card-sidecar) y el cliente de query no cambian, sólo el frontend."
[dependencies]
card-core = { workspace = true }
card-sidecar = { path = "../card-sidecar" }
chasqui-card = { path = "../chasqui-card" }
llimphi-ui = { workspace = true }
@@ -18,6 +17,10 @@ llimphi-widget-banner = { workspace = true }
llimphi-widget-card = { workspace = true }
llimphi-widget-menubar = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
llimphi-widget-skeleton = { workspace = true }
llimphi-widget-empty = { workspace = true }
llimphi-widget-toast = { workspace = true }
llimphi-icons = { workspace = true }
llimphi-motion = { workspace = true }
app-bus = { workspace = true }
rimay-localize = { workspace = true }
@@ -20,8 +20,9 @@
#![forbid(unsafe_code)]
use std::hash::{Hash, Hasher};
use std::path::PathBuf;
use std::time::Duration;
use std::time::{Duration, Instant};
use card_sidecar::{await_provider_blocking, build_consumer_card, ConsumerError};
use chasqui_card::query::client as query_client;
@@ -32,13 +33,18 @@ use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, AlignItems, Dimension, FlexDirection, Size, Style},
Rect,
};
use llimphi_ui::llimphi_raster::kurbo::Affine;
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View};
use llimphi_motion::{animate, motion, Tween};
use llimphi_icons::Icon;
use llimphi_widget_app_header::{app_header, AppHeaderPalette};
use llimphi_widget_banner::{banner_view, BannerKind};
use llimphi_widget_card::{card_view, CardOptions, CardPalette};
use llimphi_widget_empty::{empty_view, EmptyPalette};
use llimphi_widget_skeleton::{skeleton_view, SkeletonPalette};
use llimphi_widget_toast::{toast_stack_view, Toast};
use llimphi_widget_context_menu::{
context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
};
@@ -53,6 +59,18 @@ use std::sync::Arc;
const REFRESH_INTERVAL: Duration = Duration::from_secs(2);
const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(3);
const QUERY_TIMEOUT: Duration = Duration::from_secs(2);
/// Cuánto vive un toast antes de auto-descartarse.
const TOAST_TTL: Duration = Duration::from_secs(4);
/// Cadencia del repaint que anima el shimmer del skeleton mientras carga.
const SHIMMER_MS: u64 = 50;
/// Hash estable de una cadena → `key` para animaciones implícitas (la misma
/// Mónada produce siempre la misma key entre rebuilds).
fn key_of(s: &str) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
s.hash(&mut h);
h.finish()
}
struct Model {
theme: Theme,
@@ -76,6 +94,15 @@ struct Model {
/// Menú contextual sobre una Mónada: `(idx, x, y)` ancla en ventana.
/// `None` cerrado.
context_menu: Option<(usize, f32, f32)>,
/// Esperando la primera respuesta del daemon (muestra skeleton en vez de
/// hueco vacío). Baja al primer `Refresh`, sea Ok o fallo.
loading: bool,
/// Toasts vivos (errores de conexión, acciones del usuario).
toasts: Vec<Toast>,
/// Id incremental para correlacionar toast ↔ Msg de expiración.
next_toast: u64,
/// Hay una cadena de `Msg::Anim` en vuelo (evita rearmar dos).
ticking: bool,
}
#[derive(Clone)]
@@ -105,6 +132,11 @@ enum Msg {
/// `(x, y)` de ventana sobre la Mónada seleccionada. Sin selección
/// es no-op.
ContextMenuOpen(f32, f32),
/// Tick de animación — fuerza repaint para el shimmer del skeleton mientras
/// esperamos la primera respuesta. Se auto-rearma sólo si sigue cargando.
Anim,
/// Un toast cumplió su `duration`: se descarta del stack.
ToastExpire(u64),
}
#[derive(Clone)]
@@ -143,7 +175,7 @@ impl App for Explorer {
handle.dispatch(Msg::Tick);
handle.spawn_periodic(REFRESH_INTERVAL, || Msg::Tick);
Model {
let mut m = Model {
theme: Theme::dark(),
socket: None,
snapshot: None,
@@ -154,7 +186,14 @@ impl App for Explorer {
menu_anim: Tween::idle(1.0),
selected: None,
context_menu: None,
}
loading: true,
toasts: Vec::new(),
next_toast: 0,
ticking: false,
};
// Shimmer del skeleton mientras llega la primera respuesta.
ensure_anim(&mut m, handle);
m
}
fn on_key(model: &Model, event: &KeyEvent) -> Option<Msg> {
@@ -185,33 +224,47 @@ impl App for Explorer {
let prior_socket = m.socket.clone();
handle.spawn(move || Msg::Refresh(tick(prior_socket)));
}
Msg::Refresh(outcome) => match outcome {
TickOutcome::Ok { socket, source, snapshot } => {
m.socket = Some(socket);
m.socket_source = Some(source);
m.snapshot = Some(*snapshot);
m.error = None;
// Si la selección quedó fuera de rango tras el
// refresh, la descartamos.
let count = m.snapshot.as_ref().map(|s| s.monads.len()).unwrap_or(0);
if m.selected.map(|i| i >= count).unwrap_or(false) {
m.selected = None;
m.context_menu = None;
Msg::Refresh(outcome) => {
// Primera respuesta recibida: apaga el skeleton.
m.loading = false;
// Toast de error sólo en la *transición* a fallo (None→Some);
// si el daemon sigue caído el error persiste y no spammeamos.
let tenia_error = m.error.is_some();
match outcome {
TickOutcome::Ok { socket, source, snapshot } => {
m.socket = Some(socket);
m.socket_source = Some(source);
m.snapshot = Some(*snapshot);
m.error = None;
// Si la selección quedó fuera de rango tras el
// refresh, la descartamos.
let count = m.snapshot.as_ref().map(|s| s.monads.len()).unwrap_or(0);
if m.selected.map(|i| i >= count).unwrap_or(false) {
m.selected = None;
m.context_menu = None;
}
}
TickOutcome::DiscoveryFailed(msg) => {
m.socket = None;
m.socket_source = None;
m.error = Some(msg);
}
TickOutcome::QueryFailed(msg) => {
// Invalida el socket cacheado: la próxima iteración
// re-descubre.
m.socket = None;
m.socket_source = None;
m.error = Some(msg);
}
}
TickOutcome::DiscoveryFailed(msg) => {
m.socket = None;
m.socket_source = None;
m.error = Some(msg);
if !tenia_error {
if let Some(e) = m.error.clone() {
let id = m.next_toast;
m.next_toast += 1;
push_toast(&mut m, handle, Toast::error(id, e, TOAST_TTL));
}
}
TickOutcome::QueryFailed(msg) => {
// Invalida el socket cacheado: la próxima iteración
// re-descubre.
m.socket = None;
m.socket_source = None;
m.error = Some(msg);
}
},
}
Msg::MenuOpen(which) => {
m.menu_open = which;
// Abrir un menú raíz cierra cualquier contextual.
@@ -256,6 +309,11 @@ impl App for Explorer {
m.socket = None;
m.socket_source = None;
m.error = None;
m.loading = true;
ensure_anim(&mut m, handle);
let id = m.next_toast;
m.next_toast += 1;
push_toast(&mut m, handle, Toast::info(id, "Reconectando…", TOAST_TTL));
handle.dispatch(Msg::Tick);
}
Msg::SelectMonad(i) => {
@@ -270,7 +328,16 @@ impl App for Explorer {
m.context_menu = Some((i, x, y));
}
}
Msg::Anim => {
// El thread durmió; sólo rearmamos si seguimos cargando (abajo).
m.ticking = false;
}
Msg::ToastExpire(id) => {
m.toasts.retain(|t| t.id != id);
}
}
// Si quedó una carga en vuelo, mantén el shimmer animado.
ensure_anim(&mut m, handle);
m
}
@@ -321,8 +388,32 @@ impl App for Explorer {
body_children.push(banner_view::<Msg>(BannerKind::Error, e.clone()));
}
if let Some(snap) = &model.snapshot {
if model.loading && model.snapshot.is_none() {
// Esperando la primera respuesta: cards-placeholder con shimmer en
// vez de un hueco vacío. Requiere los ticks de `Msg::Anim`.
body_children.extend(skeleton_cards(theme));
} else if let Some(snap) = &model.snapshot {
body_children.push(engine_card(snap, accent_engine, theme, &card_palette));
if snap.monads.is_empty() {
// Engine vivo pero sin Mónadas todavía: estado vacío con
// orientación, no un hueco.
body_children.push(
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(220.0_f32),
},
flex_grow: 1.0,
..Default::default()
})
.children(vec![empty_view(
Icon::Search,
"Sin Mónadas",
Some("El engine está vivo pero todavía no expone Mónadas. Aparecerán aquí en cuanto el daemon las indexe."),
&EmptyPalette::from_theme(theme),
)]),
);
}
for (i, m) in snap.monads.iter().enumerate() {
let selected = model.selected == Some(i);
let card = monad_card(m, accent_data, theme, &card_palette);
@@ -336,6 +427,9 @@ impl App for Explorer {
} else {
card
};
// Pop-in: cada Mónada entra con un fade la primera vez que
// aparece su key (estable por id).
let card = card.animated_enter(key_of(&m.id.to_string()), motion::NORMAL);
body_children.push(card);
}
}
@@ -360,9 +454,13 @@ impl App for Explorer {
..Default::default()
})
.fill(theme.bg_app)
.children(body_children);
.children(body_children)
// Transición de escena: al pasar entre cargando / error / conectado la
// `scene_key` cambia y el cuerpo entra con fade + slide-up en vez de
// saltar.
.animated_enter_from(scene_key(model), motion::SLOW, Affine::translate((0.0, 24.0)));
View::new(Style {
let root = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
@@ -374,7 +472,24 @@ impl App for Explorer {
// Right-click en la raíz (origen 0,0 ⇒ local == ventana) abre el
// menú contextual sobre la Mónada seleccionada.
.on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
.children(vec![menubar, header, body])
.children(vec![menubar, header, body]);
// Overlay de toasts (bottom-right). Click en uno = descartarlo.
let now = Instant::now();
let alive: Vec<Toast> = model.toasts.iter().filter(|t| t.is_alive(now)).cloned().collect();
if alive.is_empty() {
root
} else {
let viewport = viewport_of(model);
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.children(vec![root, toast_stack_view(&alive, viewport, Msg::ToastExpire)])
}
}
fn view_overlay(model: &Model) -> Option<View<Msg>> {
@@ -419,6 +534,43 @@ impl App for Explorer {
}
}
/// Empuja un toast al stack y programa su expiración.
fn push_toast(m: &mut Model, handle: &Handle<Msg>, toast: Toast) {
let id = toast.id;
m.toasts.push(toast);
handle.spawn(move || {
std::thread::sleep(TOAST_TTL);
Msg::ToastExpire(id)
});
}
/// Arranca la cadena de ticks de shimmer si seguimos esperando la primera
/// respuesta y no hay ya una corriendo. La cadena se auto-detiene cuando
/// `loading` baja (ver `Msg::Anim`), así no queda un repaint ocioso.
fn ensure_anim(m: &mut Model, handle: &Handle<Msg>) {
if m.ticking || !m.loading {
return;
}
m.ticking = true;
handle.spawn(move || {
std::thread::sleep(Duration::from_millis(SHIMMER_MS));
Msg::Anim
});
}
/// `key` estable de la escena del cuerpo. Cambia al pasar entre cargando /
/// error / conectado → dispara la transición de entrada del cuerpo; estable
/// mientras la escena no cambia.
fn scene_key(m: &Model) -> u64 {
if m.loading && m.snapshot.is_none() {
key_of("cargando")
} else if m.error.is_some() && m.snapshot.is_none() {
key_of("error")
} else {
key_of("conectado")
}
}
/// Viewport para clampear overlays: tamaño de ventana del Model si lo
/// llevara; como el explorer no lo trackea, usamos `initial_size()`.
fn viewport_of(_model: &Model) -> (f32, f32) {
@@ -704,6 +856,51 @@ fn text_line(text: &str, color: Color, _theme: &Theme) -> View<Msg> {
.text_aligned(text.to_string(), 11.0, color, Alignment::Start)
}
/// Cards-placeholder con shimmer mientras llega la primera respuesta del
/// daemon — el usuario ve la forma de lo que viene, no un hueco. Requiere los
/// repaints periódicos de `Msg::Anim` mientras `loading`.
fn skeleton_cards(theme: &Theme) -> Vec<View<Msg>> {
let pal = SkeletonPalette::from_theme(theme);
// Un bloque lleno y clipeado para que el `skeleton_view` (absolute) pinte
// dentro de su caja.
let bloque = move |w: Dimension, h: f32| -> View<Msg> {
View::new(Style {
size: Size { width: w, height: length(h) },
flex_shrink: 0.0,
..Default::default()
})
.radius(4.0_f64)
.clip(true)
.children(vec![skeleton_view::<Msg>(&pal)])
};
(0..4)
.map(|_| {
let titulo = bloque(percent(0.45_f32), 16.0);
let l1 = bloque(percent(0.7_f32), 11.0);
let l2 = bloque(percent(0.55_f32), 11.0);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(12.0_f32),
bottom: length(12.0_f32),
},
gap: Size { width: length(0.0_f32), height: length(8.0_f32) },
flex_shrink: 0.0,
..Default::default()
})
.fill(theme.bg_panel)
.radius(8.0_f64)
.children(vec![titulo, l1, l2])
})
.collect()
}
fn tick(prior_socket: Option<PathBuf>) -> TickOutcome {
let (socket, source) = match prior_socket {
Some(p) => (p, "cache"),
@@ -70,7 +70,7 @@ impl EmbedCache {
}
/// Almacena. Errores se loggean pero no propagan — cache miss es
/// recuperable, no querés tirar el embed válido por fallo de I/O
/// recuperable, no quieres tirar el embed válido por fallo de I/O
/// de cache.
pub fn put(&self, file_sha: &[u8; 32], model_id: &str, embedding: &[f32]) {
let key = build_key(file_sha, model_id);
@@ -139,7 +139,7 @@ mod tests {
use super::*;
fn sha(s: &[u8]) -> [u8; 32] {
arje_cas::sha256_of(s)
arje_cas::blake3_of(s)
}
#[test]
@@ -135,7 +135,7 @@ fn handle_file(
// bajo la semántica del proveedor (el modelo nunca vio los bytes
// adicionales). Si la cabeza cambia, el hash cambia y caemos a
// re-embed naturalmente.
let file_sha = arje_cas::sha256_of(&buf);
let file_sha = arje_cas::blake3_of(&buf);
if let Some(cache) = cache {
if let Some(cached) = cache.get(&file_sha, model_id) {
@@ -63,7 +63,7 @@ async fn main() -> std::io::Result<()> {
#[cfg(not(feature = "embeddings"))]
info!(
"chasqui-nous-real corriendo en modo STUB (compilá con \
"chasqui-nous-real corriendo en modo STUB (compila con \
--features embeddings para activar el modelo)"
);
+1 -1
View File
@@ -24,7 +24,7 @@
//! El consumidor (chasqui-core) y el proveedor (chasqui-nous-mock,
//! chasqui-nous-real) deben acordar en types EXACTOS. Tener el contrato
//! en su crate evita que cada lado declare structs paralelos que se
//! desincronizan. Si bumpeás el wire, bumpeás aquí.
//! desincronizan. Si bumpeas el wire, bumpeas aquí.
//!
//! ## Swap por priority_contexts
//!
Generated
+1549 -764
View File
File diff suppressed because it is too large Load Diff
+326 -131
View File
@@ -1,7 +1,13 @@
# Cargo.toml raíz STANDALONE de chasqui — front-door sobre Llimphi.
# Solo el código de chasqui; Llimphi y lo fundacional por git-dep del monorepo gioser.git.
# Cargo.toml raíz STANDALONE de chasqui — front-door público.
# GENERADO por scripts/actualizar-standalone.py desde el monorepo tawasuyu.git
# (v0.2.0). No editar a mano: se regenera. Lo fundacional se consume por
# git-dep al tag; sólo el código del dominio vive en este repo.
[workspace]
resolver = "2"
# ============================================================
# Cuadrantes — agregar a medida que se migran
# ============================================================
members = [
"02_ruway/chasqui/card-admin",
"02_ruway/chasqui/card-handshake",
@@ -16,6 +22,11 @@ members = [
"02_ruway/chasqui/chasqui-nous-real",
]
# `wawa/` se excluye del workspace global porque corre en target
# `x86_64-unknown-none` y `panic = "abort"`, incompatibles con los
# perfiles globales. Los crates compartidos se referencian por `path`
# cruzando la frontera.
[workspace.package]
version = "0.1.0"
edition = "2021"
@@ -26,9 +37,32 @@ publish = false
repository = "https://git.tawasuyu.net/tawasuyu/chasqui"
[workspace.dependencies]
# === Configuración declarativa (vocabulario de esquemas) ===
allichay = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
marca = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# Telemetría local de desarrollo (stderr+panics+tracing → $XDG_STATE_HOME/tawasuyu/<dominio>/<bin>.log)
bitacora = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# default = [] (núcleo liviano sin vello); `bake` es opt-in. Declaramos
# default-features = false aquí para que el `default-features = false` de los
# consumidores (splash/greeter/compositor) se honre y no quede el warning.
mirada-fondo = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
mirada-teclado-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
mirada-teclado-widget = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# === Registro de apps / menú global ===
app-bus = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
app-bus = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
app-iconset = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# === Centro de eventos (notificaciones + capturas + clipboard + …) ===
willay-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
willay-checkpoint = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
willay-hilo = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
willay-store = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
willay-emit = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
rag-motor = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
churay-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
churay-welcome = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
churay-welcome-runner = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# === Puente de drag-and-drop compositor → app (suple winit en Wayland) ===
drop-bridge = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# === Serialización ===
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -55,8 +89,12 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
# === Linux primitives (arje) ===
nix = { version = "0.29", features = ["signal", "process", "sched", "mount", "fs", "socket", "net", "user"] }
nix = { version = "0.29", features = ["signal", "process", "sched", "mount", "fs", "socket", "net", "user", "reboot"] }
libc = "0.2"
# DRM/KMS legacy (dumb buffer) para el splash nativo del arranque sin parpadeo.
# Misma versión que reexporta smithay (mirada-compositor) — el lock ya la fija.
drm = "0.14"
font8x8 = { version = "0.3", default-features = false }
# === IDs / Hash / Crypto ===
ulid = { version = "1", features = ["serde"] }
@@ -70,10 +108,15 @@ argon2 = "0.5"
rand = "0.8"
# === WASM (arje) ===
# wasmi 1.0: unifica la versión con renaser (su kernel ya corre 1.0), para
# wasmi 1.0: unifica la versión con wawa (su kernel ya corre 1.0), para
# que el ABI WASM del host sea idéntico en Linux y en bare-metal.
wasmi = "1.0"
wat = "1"
# wasmtime 46 (Fase 1 del pipeline JS): tier 0 de puriy-js pasa de wasmi
# (intérprete) a Wasmtime AOT (Cranelift → nativo) en Linux — quita la doble
# interpretación de QuickJS.wasm. En wawa bare-metal el runtime es Pulley (spike
# Q1 cerrado); mismo crate, distinto backend.
wasmtime = "46"
# === Storage / DB ===
sled = "0.34"
@@ -88,6 +131,10 @@ bzip2 = "0.4"
# === Compresión (minga multi-bundle) ===
zstd = "0.13"
xz2 = "0.1"
# Contenedores adicionales del visor Archive de nahual (sólo listado).
sevenz-rust = "0.6"
unrar = "0.5"
# === HTTP server (iniy-server) ===
axum = "0.7"
@@ -97,7 +144,7 @@ tower = "0.5"
instant-distance = "0.6"
# === P2P (minga) ===
libp2p = { version = "0.56", features = ["tokio", "tcp", "noise", "yamux", "macros", "kad", "identify", "relay", "dcutr", "autonat", "mdns"] }
libp2p = { version = "0.56", features = ["tokio", "tcp", "dns", "noise", "yamux", "macros", "kad", "identify", "relay", "dcutr", "autonat", "mdns"] }
libp2p-stream = "=0.4.0-alpha"
libp2p-allow-block-list = "0.6"
@@ -112,7 +159,11 @@ libm = "0.2"
midly = "0.5"
# === Code parsing (minga) ===
arboard = "3"
# `wayland-data-control` trae el backend wl-clipboard-rs: bajo mirada (Wayland
# propio, sin Xwayland para apps nativas) arboard-x11 fallaba a no-op silencioso
# y el clipboard de los text-editor quedaba muerto. Con la feature habla
# zwlr_data_control_manager_v1 (que mirada expone) y cae a X11 si no hay Wayland.
arboard = { version = "3", features = ["wayland-data-control"] }
ropey = "1.6"
tree-sitter = "0.24"
tree-sitter-rust = "0.23"
@@ -131,7 +182,19 @@ petgraph = "0.6"
# default-features = false: nos quedamos con PNG + JPEG + WebP (lossless).
# tullpu-render exporta a las tres; AVIF/TIFF/… los habilitamos si una app
# los pide específicamente.
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
# Decoders puro-Rust baratos; avif queda fuera (arrastra dav1d/rav1e).
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "ico", "tiff", "tga", "qoi"] }
# ONNX Runtime para los proveedores de píxel reales (pixel-verbo-onnx: segmentación
# u2net/isnet, inpaint, restyle…). `download-binaries` (default) baja el runtime nativo
# solo; ya lo arrastraba fastembed (rimay-verbo) transitivo — aquí lo declaramos directo.
ort = "2.0.0-rc.9"
# Rasterizador vectorial CPU (relleno/trazo anti-aliased) para capas vectoriales de tullpu.
tiny-skia = "0.11"
# Booleanos de polígonos robustos (union/intersection/difference/xor) para el
# clipper vectorial de tullpu — restar/intersecar paths que se solapan
# parcialmente, que la regla de relleno no expresa. Ya estaba en el lock.
geo = "0.28"
# === FUSE (minga-vfs) ===
# default-features = false: prescinde de pkg-config/libfuse-dev en build.
@@ -153,91 +216,164 @@ tempfile = "3"
# === Llimphi (motor gráfico soberano) ===
# wgpu sobre Vulkan/Metal/DX12, winit para ventana en dev Linux.
# raw-window-handle 0.6 alinea winit 0.30 con wgpu 24.
# vello 0.5 = rasterizador vectorial sobre wgpu 24.
# raw-window-handle 0.6 alinea winit 0.30 con wgpu 27.
# vello 0.7 = rasterizador vectorial sobre wgpu 27 (renderer GPU "wgpu" + opt-in "hybrid" CPU+GPU).
# taffy 0.9 = motor Flexbox/Grid puro Rust (ya pulled por transitivos, lo alineamos).
# parley 0.2 = shaping/layout de texto compatible con peniko 0.4 (que vello 0.5 expone).
wgpu = "24"
# parley 0.6 = shaping/layout de texto compatible con peniko 0.6 (que vello 0.7 expone).
wgpu = "27"
winit = "0.30"
raw-window-handle = "0.6"
pollster = "0.4"
vello = "0.5"
vello = "0.7"
# Renderer hybrid CPU+GPU sin compute shaders (mejor compat WebGL2/Adreno viejas).
# Opt-in; el renderer "wgpu" sigue siendo el default.
vello_hybrid = "0.0.9"
# foreign-lottie = fork vendorizado de velato 0.9 (Lottie → vello::Scene),
# completado para no paniquear ante features no soportadas. Lo consume
# llimphi-lottie. Ver shared/foreign-lottie.
foreign-lottie = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# Árbol de accesibilidad para lectores de pantalla (NVDA/VoiceOver/Orca/TalkBack).
# Lo consume el runtime de Llimphi (iter 2/3 del plan AccessKit); el modelo
# `SemanticsSpec` del compositor es independiente de estas crates.
accesskit = "0.24"
accesskit_winit = "0.33"
taffy = "0.9"
# parley = shaping completo (bidi, ligatures, fallback CJK/emoji vía fontique, line break).
parley = "0.4"
parley = "0.6"
# Bucle Elm (input→update→view→layout→raster→present). Lo consumen las apps.
llimphi-ui = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-ui = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# Tier 3 — apps WASM con UI Llimphi real. El guest pinta un WireNode (IR
# serializable), el host (runner) lo materializa en View<Msg> y rebota los
# eventos al update del guest. SDK = lado guest, runner = lado host.
llimphi-wire-view = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-wasm-app-sdk = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-wasm-runner = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# Núcleo puro de distribución (CAS + verificación + resolve), SIN runner/GPU.
llimphi-wasm-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# Cara con baterías: re-exporta core + puente al runner para correr la app.
llimphi-wasm-dist = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-wasm-open = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-wasm-registry = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-wasm-wasi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# Transporte P2P del bytecode por hash sobre BrahmanNet (card-net).
llimphi-wasm-net = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-3d = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# Runner wlr-layer-shell: corre un `App` de Llimphi como barra anclada a un
# borde (no como ventana), reusando la plumbing sctk+wgpu que pata probó.
llimphi-layer = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# Paleta semántica compartida por las apps y los widgets.
llimphi-theme = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-theme = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# Tweens y helpers de animación sobre el bucle Elm.
llimphi-motion = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-motion = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# Máquina de estados de animación (estilo Rive) clip-agnóstica. Núcleo puro;
# el render lo cablea el consumidor (llimphi-lottie). Escalón sobre el playback.
llimphi-anim = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# Studio de animación «rive»: además del editor (bin), expone como lib los
# documentos serializables (Doc + RigDoc + Project) que carga mirada-fondo.
llimphi-anim-studio = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# Render de mallas deformables (skel::Mesh) a vello Scene: vectorial y texturizada.
llimphi-mesh = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# Iconos vectoriales (BezPath en grid 24×24) compartidos por todas las apps.
llimphi-icons = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-icons = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# Puente fino vello_svg → Llimphi para SVG arbitrario (íconos .desktop, logos).
llimphi-svg = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# Puente fino velato (Lottie) → Llimphi para animación vectorial autorada (.json).
llimphi-lottie = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-image = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# Decodificador de protocolos de gráficos de terminal (kitty/sixel) → RGBA.
llimphi-term-graphics = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# Widgets reusables sobre llimphi-ui — uno por crate.
llimphi-widget-app-header = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-banner = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-button = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-card = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-clipboard = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-context-menu = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-edit-menu = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-menubar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-list = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-grid = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-slider = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-scroll = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-splitter = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-stat-card = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-tabs = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-module-command-palette = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-module-diff-viewer = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-module-fif = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-module-file-picker = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-module-bookmarks = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-module-mini-map = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-module-shuma-term = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-module-symbol-outline = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-plugin-host = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-theme-switcher = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-text-area = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-text-editor-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-text-editor = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-text-editor-lsp = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-text-input = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-tiled = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-nodegraph = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-tree = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-navigator = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-app-header = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-banner = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-button = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-rive-button = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-card = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-clipboard = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-context-menu = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-edit-menu = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-menubar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-list = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-grid = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-table = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-color-picker = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-slider = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-scroll = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-lazy-list = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-splitter = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-stat-card = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-tabs = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-module-allichay = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-module-command-palette = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-module-diff-viewer = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-module-fif = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-module-file-picker = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-module-bookmarks = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-module-mini-map = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-module-shuma-term = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-module-symbol-outline = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-plugin-host = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-theme-switcher = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-text-area = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-text-editor-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-text-editor = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-text-editor-lsp = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-text-input = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-tiled = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-nodegraph = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-tree = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
grafo-nav = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-navigator = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-detail-table = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-select = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-terminal = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# Sello vectorial wawa (rombo + W implícita + Merkle Core).
llimphi-widget-wawa-mark = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-wawa-mark = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# Widgets de elegancia transversal (tooltip, spinner, progress, toast,
# modal, empty, status-bar, shortcuts-help, splash).
llimphi-widget-tooltip = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-spinner = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-progress = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-toast = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-modal = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-empty = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-status-bar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-shortcuts-help = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-timeline = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-splash = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-tooltip = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-spinner = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-progress = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-toast = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-modal = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-empty = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-status-bar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-shortcuts-help = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-timeline = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-transport = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-waveform = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-splash = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# Controles de formulario y signaling (switch, segmented, breadcrumb,
# badge, avatar, skeleton, field).
llimphi-widget-switch = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-segmented = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-dock-rail = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-breadcrumb = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-badge = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-avatar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-skeleton = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-field = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-switch = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-segmented = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-rag-sidebar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-dock-rail = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-toolbar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-breadcrumb = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-badge = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-avatar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-skeleton = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-field = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# Firma visual transversal (gradient sutil + hairline accent).
llimphi-widget-panel = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-panes = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-workspace = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-widget-panel = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-panes = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# Widgets Flutter-like — composición sobre primitivas Tier 1/3 ya
# expuestas (sombra, gradient, animated, ripple).
llimphi-widget-chip = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-fab = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-wrap = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-range-slider = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-calendar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-fitted-box = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-carousel = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-rating = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-gauge = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-scaffold = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-hero = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-widget-router = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
llimphi-workspace = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# Abstracción Selector — host (paths) + wawa (khipus).
llimphi-module-selector = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
llimphi-module-selector = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# === Filesystem helpers ===
directories = "5"
@@ -271,7 +407,7 @@ hex = "0.4"
portable-pty = "0.9"
vt100 = "0.16"
# === WASM web (gioser) ===
# === WASM web (tawasuyu) ===
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
@@ -296,61 +432,92 @@ ttf-parser = "0.25"
# ============================================================
# Intra-workspace deps de nahual (referenciadas por workspace = true)
# ============================================================
nahual-text-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
nahual-image-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
nahual-thumb-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
nahual-gallery-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
nahual-video-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
nahual-card-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
nahual-audio-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
nahual-tree-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
nahual-hex-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
nahual-table-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
nahual-markdown-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
nahual-archive-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
nahual-font-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
nahual-map-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
nahual-geo-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
nahual-viewer-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
nahual-file-explorer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
nahual-text-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
nahual-image-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
nahual-pdf-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
nahual-cbz-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
nahual-thumb-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
nahual-gallery-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
nahual-video-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
nahual-card-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
nahual-audio-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
media-module = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
tullpu-module = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
tullpu-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
tullpu-icon-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
tullpu-icon-cli = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
tullpu-icon-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
tullpu-icon-llm = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
foreign-svg = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
foreign-pdf = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
tullpu-render = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
tullpu-render-gpu = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
tullpu-ops = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
tullpu-paint = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
nahual-tree-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
nahual-hex-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
nahual-svg-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
nahual-sheet-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
nahual-dbf-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
nahual-table-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
nahual-markdown-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
nahual-pluma-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
nahual-cotejo-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
nahual-deck-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
nahual-archive-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
nahual-font-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
nahual-map-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
nahual-geo-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
nahual-geo-voxel = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
nahual-iconos = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
nahual-viewer-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
nahual-file-explorer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# ============================================================
# Intra-workspace deps de pineal (módulo de gráficos)
# ============================================================
pineal-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
pineal-render = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
pineal-cartesian = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
pineal-stream = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
pineal-mesh = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
pineal-financial = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
pineal-polar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
pineal-heatmap = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
pineal-treemap = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
pineal-flow = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
pineal-phosphor = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
pineal-export = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
pineal-hexbin = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
pineal-contour = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
pineal-bars = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
pineal = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
pineal-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
pineal-render = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
pineal-cartesian = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
pineal-stream = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
pineal-mesh = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
pineal-financial = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
pineal-polar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
pineal-heatmap = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
pineal-treemap = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
pineal-flow = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
pineal-phosphor = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
pineal-export = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
pineal-hexbin = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
pineal-contour = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
pineal-bars = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
pineal = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# ============================================================
# Intra-workspace deps de iniy (laboratorio semántico de creencias)
# ============================================================
iniy-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
iniy-ingest = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
iniy-extract = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
iniy-nli = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
iniy-nli-llm = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
iniy-graph = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
iniy-store = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
iniy-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
iniy-evidencia = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
iniy-derive = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
iniy-emisores = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
iniy-cache = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
iniy-daemon = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
iniy-ingest = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
iniy-extract = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
iniy-nli = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
iniy-nli-llm = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
iniy-graph = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
iniy-store = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# la física de decaimiento de khipu la importa iniy-derive (SDD iniy §3)
khipu-gravity = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# === auto: declarados por crates internos faltantes ===
cosmos-coords = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
cosmos-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
cosmos-ephemeris = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
cosmos-time = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
cosmos-wcs = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
cosmos-coords = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
cosmos-cities = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
cosmos-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
cosmos-ephemeris = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
cosmos-time = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
cosmos-wcs = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
# === auto: externas de eternal ===
celestial-eop-data = { version = "0.1"}
@@ -358,6 +525,7 @@ approx = "0.5"
byteorder = "1.5"
cc = "1.0"
chrono = "0.4"
chrono-tz = "0.10"
crc32fast = "1.4"
criterion = "0.5"
csv = "1.4"
@@ -366,6 +534,11 @@ glob = "0.3"
indicatif = "0.18"
lz4_flex = "0.11"
memmap2 = "0.9"
# Cliente Wayland (lado cliente) — lo usa hapiy para hablar zwlr_screencopy
# contra mirada (u otro compositor wlroots) y capturar la pantalla.
wayland-client = "0.31"
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
wayland-protocols-misc = { version = "0.3", features = ["client"] }
mockito = "1.0"
ndarray = "0.15"
num-traits = "0.2"
@@ -399,6 +572,9 @@ markup5ever_rcdom = "0.39"
cssparser = "0.35"
url = "2"
ureq = { version = "2", default-features = false, features = ["tls"] }
# Charset de la web real: no todo es UTF-8 (google.com sirve ISO-8859-1).
# Ya venía transitivo por el stack de Servo — aquí se hace explícito.
encoding_rs = "0.8"
# === takiy-synth (SoundFont MIDI) ===
# rustysynth = sintetizador SF2 puro Rust, MIT. Reemplaza el oscilador
@@ -427,7 +603,7 @@ hound = "3.5"
symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg"] }
# === media-source-opus (decoder Opus NATIVO puro-Rust) ===
# Opus es el formato de audio nativo de gioser (par del video AV1). ogg
# Opus es el formato de audio nativo de tawasuyu (par del video AV1). ogg
# demuxea las páginas Ogg; opus-wave es un port puro-Rust de libopus
# (SILK+CELT, sin C ni FFI) — par del rav1d del lado video.
ogg = "0.9"
@@ -438,11 +614,30 @@ opus-wave = "3"
# paquetes de los tracks V_AV1 y A_OPUS para alimentar a media-source-av1
# y media-source-opus — un .webm AV1+Opus se reproduce 100% nativo.
matroska-demuxer = "0.7"
# === git-deps al monorepo (agregados por la extracción) ===
arje-cas = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
card-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
card-net = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
card-wit = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
rimay-localize = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
shuma-discern = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
wawa-config = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
# === Perfil `dist`: binarios de reparto livianos (equipos chicos / poca RAM) ===
# Hereda de `release` pero prioriza HUELLA sobre tiempo de compilación: LTO thin
# (poda código muerto entre crates), un solo codegen-unit (mejor inlining/DCE) y
# `strip` de símbolos. Reduce el tamaño en disco Y el segmento de código mapeado
# en RAM de cada binario (mirada, pata, apps Llimphi). No toca `cargo build
# --release` de iteración diaria; se usa sólo al empaquetar:
# cargo build --profile dist -p mirada-compositor -p pata-host …
# `panic = "unwind"` se mantiene a propósito: mirada-plugin-host aísla plugins
# con `catch_unwind`, así que `abort` rompería el sandbox.
# === Crates del monorepo consumidos por git-dep (pin del tag) ===
arje-cas = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
card-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
card-net = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
card-wit = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
rimay-localize = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
shuma-discern = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
wawa-config = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
[profile.dist]
inherits = "release"
lto = "thin"
codegen-units = 1
strip = "symbols"
panic = "unwind"