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:55 +00:00
parent 6f4d193835
commit 48e5a861a0
35 changed files with 7166 additions and 3788 deletions
+46 -48
View File
@@ -1,8 +1,19 @@
# khipu
> `khipu` (quechua: nudos de cuerdas para registrar memoria). Notas con gravedad temporal.
> `khipu` (quechua: nudos de cuerdas para registrar memoria). El cuaderno de resonancia: las ideas se refuerzan entre sí, no se olvidan.
Captura de notas rápidas donde el olvido es parte del modelo: cada nota tiene una masa que decae con el tiempo y se refuerza con cada acceso. Lo recurrente queda visible; lo que no se vuelve a tocar se va difuminando hasta caer del horizonte.
Un lugar donde botar ideas sueltas en cualquier estado mental — pensamientos, TODOs, citas — y que la estructura **emerja de la recurrencia** en vez de imponerse por adelantado. La inversa de un gestor de notas con carpetas y la inversa del khipu viejo (que olvidaba por decay): aquí cada idea que vuelve **refuerza** a las que ya estaban. El objetivo es cerrar las fugas cerebrales: la idea que se te va y vuelves a pensar desde cero.
## El modelo: nudos, cuerdas, tejido
- **Nudo** — la unidad de captura. Escribes, Enter, listo: cero organización en el momento. Un nudo puede marcarse pendiente/hecho (TODO sin due-dates) y lleva estampa de origen si vino de otra app (fase 2: chasqui, pata, pacha).
- **Resonancia** — al capturar, khipu busca los nudos previos afines (embeddings) y te los muestra ahí mismo: *«esto resuena con…»*. Un click anuda el nudo nuevo a la cuerda del vecino, o crea una cuerda nueva con ambos. Ese instante es el cierre de la fuga.
- **Cuerda** — una idea recurrente. No se crea por adelantado: nace cuando dos nudos resuenan. Se bautiza con un nombre propuesto del contenido (editable). Su **masa** crece con cada nudo; su **brillo**, con la frescura del último crecimiento. Nada decae: lo que no resuena **sedimenta** (queda abajo, buscable, sin estorbar).
- **Tejido** — cuando una cuerda engorda pero la idea sigue difusa, «tejer» le pide al LLM un encuadre de sus nudos: *qué se repite, qué ya está claro, qué falta decidir*. El resultado es un nudo-síntesis **editable** al final de la cuerda (rombo ◈); re-tejer lo reemplaza. Es la función para la que khipu existe: el caso canónico fue la idea de khipu mismo.
## La ventana es un khipu
Captura siempre lista arriba; franja de sueltos frescos; y el lienzo: una viga de la que cuelgan las cuerdas ordenadas por masa (la más gorda a la izquierda), con sus nudos en cronología. Pendiente = anillo hueco, hecho = relleno tenue, tejido = rombo. Click en un nudo abre el editor (overlay derecho); click en la cabeza de una cuerda abre su tarjeta (tejer / renombrar / desatar). `☰ nudos` abre el cajón con búsqueda; el sedimento vive ahí. `Esc` cierra en cascada. Pan con arrastre o rueda.
## Instalación
@@ -10,69 +21,56 @@ Captura de notas rápidas donde el olvido es parte del modelo: cada nota tiene u
cargo run --release -p khipu-app
```
## Compatibilidad
### Embeddings reales (resonancia semántica de verdad)
- **Linux / macOS / Windows** — UI Llimphi (Wayland/X11/Win32 vía `winit`).
- Persistencia local en `$XDG_DATA_HOME/khipu/`.
```sh
cargo run -p rimay-verbo-daemon-bin -- --provider fastembed # escucha en $XDG_RUNTIME_DIR/verbo.sock
cargo run --release -p khipu-app # lo detecta solo al arrancar
```
Sin daemon, khipu cae al embebedor trigram de 16d — determinista y offline. Si el espacio vectorial cambia entre dos arranques, los vectores se recalculan solos.
### Tejido (LLM)
El backend sale de `pluma-llm::from_env()`: `ANTHROPIC_API_KEY` / `GEMINI_API_KEY` / `DEEPSEEK_API_KEY` / `COHERE_API_KEY` / Ollama, o `PLUMA_LLM_BACKEND` explícito. **Sin credenciales cae a Mock** — el botón siempre responde, aunque sea un eco de prueba.
## Persistencia y migración
`$XDG_DATA_HOME/khipu/notes.bin` (postcard, formato V4: nudos + cuerdas). Los cuadernos del khipu viejo (V1V3, el del mapa gravitatorio) **migran solos** al abrir: las notas conservan id y contenido, nacen sueltas (las cuerdas se anudan de ahí en más), y el archivo original queda respaldado en `notes.v3.bak`.
## Crates
| Crate | Rol |
|---|---|
| [`khipu-core`](khipu-core/README.md) | Modelo de nota + store; sin UI. |
| [`khipu-gravity`](khipu-gravity/README.md) | Algoritmo de masa/decay; refuerzo por acceso. |
| [`khipu-core`](khipu-core/README.md) | Nudos + cuerdas + grafo de enlaces + propuesta de nombre; sin UI. |
| [`khipu-gravity`](khipu-gravity/README.md) | Las físicas puras: campo semántico (resonancia), masa/frescura de cuerda, y el decay temporal (que ya no usa la app — lo consumen pata e iniy-derive). |
| `khipu-share` | Sobres de notas firmados (Ed25519) y direccionados por contenido (BLAKE3) sobre agora; transporte TCP/LAN + descubrimiento UDP + identidad cifrada. |
| `khipu-brahman` | Transporte de sobres sobre libp2p (BrahmanNet): stream cifrado + descubrimiento por DHT. |
| [`khipu-app`](khipu-app/README.md) | UI Llimphi sobre el core. |
## Gravedad semántica (embeddings)
El canvas de la derecha agrupa las notas por afinidad. Los vectores salen del `verbo-daemon` si está corriendo; si no, de un hash-trigram local.
```sh
# Embeddings reales (clústeres y vecinos semánticos de verdad):
cargo run -p rimay-verbo-daemon-bin -- --provider fastembed # escucha en $XDG_RUNTIME_DIR/verbo.sock
cargo run --release -p khipu-app # lo detecta solo al arrancar
```
Sin daemon, khipu cae al embebedor trigram de 16d — determinista y offline, idéntico al comportamiento histórico. El cálculo nunca bloquea la UI: viaja a un worker y reentra al bucle cuando termina. Si el espacio vectorial cambia entre dos arranques (arrancó/cayó el daemon, otro modelo), los vectores se recalculan automáticamente.
| [`khipu-app`](khipu-app/README.md) | UI Llimphi: captura, lienzo de cuerdas, resonancia, tejido. |
## Compartir (agora)
`exportar` sella en `compartido.khipu` un sobre firmado Ed25519 con la identidad del cuaderno y direccionado por su hash BLAKE3 de contenido.
Sin cambios de fondo respecto del khipu viejo — viaja el **contenido** (título, cuerpo, etiquetas), nunca la estructura local: al importar, cada nota nace suelta y fresca; las cuerdas son atención de quien las anudó y no se transfieren. `exportar` sella un sobre firmado Ed25519 direccionado por BLAKE3 (filtrado por la búsqueda si hay texto); `importar` verifica firma + hash, deduplica por título y marca procedencia `de:<autor>`. `publicar`/`recibir`: TCP + baliza UDP en LAN, y libp2p (Noise sobre BrahmanNet, Circuit Relay v2 + DCUtR + AutoNAT, DHT Kademlia) para WAN — `KHIPU_BIND`, `KHIPU_RELAY`, `KHIPU_BOOTSTRAP`, `KHIPU_PASSPHRASE` como siempre. La identidad vive cifrada (Argon2id + ChaCha20-Poly1305) en `<datos>/keys/`.
La identidad vive **cifrada** (Argon2id + ChaCha20-Poly1305, vía `agora-keystore`) en `<datos>/keys/` — la semilla privada nunca queda en claro en disco. Al primer intento de compartir, khipu pide una passphrase: la primera vez la crea, después la usa para descifrar. `KHIPU_PASSPHRASE` en el entorno desbloquea sin prompt (headless). Una `identidad.seed` en claro de versiones viejas se migra al keystore (y se borra el claro) automáticamente. Comparte **lo que el buscador esté filtrando** (vacío = todo el cuaderno), así que escribir en la búsqueda y exportar manda sólo ese subconjunto. `importar` verifica firma + hash y, si cuadra, ingiere las notas, marcándolas con una etiqueta de procedencia `de:<autor>` (visible en el editor como «✎ de: …»).
Lo que viaja es el **contenido** (título, cuerpo, etiquetas), nunca la física temporal: al importar, cada nota nace fresca (masa plena, acceso = ahora) — su gravedad arranca en el cuaderno que la recibe. Los wiki-links `[[Título]]` se rearman solos porque khipu resuelve enlaces por título. Reimportar el mismo sobre no duplica (se omiten títulos ya presentes). Un sobre alterado o con firma ajena se rechaza entero, sin autoridad central.
Para compartir en vivo sin copiar archivos: `publicar` levanta un servidor TCP que sirve el cuaderno (puerto `KHIPU_BIND`, default `127.0.0.1:7700`) **y anuncia una baliza UDP** para que lo descubran en la LAN. `recibir` abre un panel con un **campo de dirección** (`host:puerto`, prellenado y editable) y, debajo, los **pares descubiertos en la LAN** (nombre · autor · dirección): click en uno para jalarle el cuaderno, o escribí una dirección y «jalar». El transporte es `std::net` puro y **no necesita ser confiable** — el receptor verifica firma + hash antes de ingerir; la baliza sólo dice *dónde*, no *qué*.
**WAN / libp2p**: el campo de dirección de `recibir` acepta dos formas, autodetectadas: `host:puerto` (TCP directo) o una **multiaddr libp2p** —directa `/ip4/…/p2p/<id>` o de circuito `/ip4/…/p2p/<relay>/p2p-circuit/p2p/<id>`. Al `publicar`, khipu sirve por libp2p (stream cifrado Noise sobre `BrahmanNet`, protocolo `/khipu/sobre/1.0.0`, vía `khipu-brahman`) y muestra tu dirección de marcado.
**NAT traversal**: `BrahmanNet` ahora trae **Circuit Relay v2 + DCUtR** (`card-net`). Un nodo alcanzable hace de relay; uno detrás de NAT reserva un circuito ahí y queda accesible vía la dirección de circuito. Configurá `KHIPU_RELAY=/ip4/…/tcp/…/p2p/<relay-id>` antes de `publicar` y khipu reserva el circuito y muestra la dirección para compartir. Las direcciones externas no se confían a ciegas: **AutoNAT** las confirma pidiendo dial-backs a otros peers, y sólo las confirmadas se anuncian (y entran en las reservas de relay). En la malla Brahman AutoNAT corre con `only_global_ips=false` para confirmar también en LAN/loopback.
**Descubrimiento por DHT**: con `KHIPU_BOOTSTRAP=/ip4/…/p2p/<id>` (un nodo de la malla), khipu se une a la DHT Kademlia al arrancar; `publicar` se anuncia bajo la clave khipu y `recibir` lista —además de los pares LAN— los pares hallados por DHT (filas `DHT · …<id>`), que se jalan por peer-id. Así dos khipu se encuentran sin compartir IP ni multiaddr a mano, sólo conociendo un bootstrap común. Verificado end-to-end en localhost (rendezvous + publicador + receptor; 4 tests en `khipu-brahman`).
La lógica vive en `khipu-share`: `net` (transporte TCP) y `discovery` (baliza UDP). 15 tests + un test de integración que recorre la cadena completa descubrir→jalar→verificar en loopback.
## Estado (2026-05-31)
## Estado (2026-07-13)
### Hecho
- `khipu-core` (modelo de nota + store) + `khipu-gravity` (masa/decay con refuerzo por acceso).
- `khipu-app`: UI Llimphi sobre el core, con menú principal y contextual.
- Gravedad semántica: clustering por embeddings del `verbo-daemon` (rimay), con fallback trigram 16d offline; cálculo en worker que no bloquea la UI.
- Compartir vía agora (`khipu-share`): sobres firmados Ed25519 + direccionados BLAKE3, identidad cifrada (Argon2id + ChaCha20-Poly1305 en keystore), compartir selectivo + procedencia del autor; transporte TCP/LAN + descubrimiento por baliza UDP (15 tests + integración loopback).
- WAN/P2P (`khipu-brahman` sobre libp2p/BrahmanNet): stream cifrado Noise, NAT traversal (Circuit Relay v2 + DCUtR), AutoNAT, descubrimiento por DHT Kademlia (4 tests e2e localhost).
- **Rediseño completo**: se invirtió la física (resonancia en vez de olvido) y murió el mapa gravitatorio como interfaz. Vista raíz = captura + cuerdas.
- `khipu-core`: modelo nudo/cuerda (`Note.cuerda/estado/origen`, `Cuerda.tejido`), store con anudar/desanudar/sueltos, propuesta de nombre.
- `khipu-gravity`: `masa_cuerda` + `frescura` (puras); el decay temporal sigue para sus otros consumidores.
- `khipu-app`: lienzo de cuerdas (painter vello + hit-test + pan), panel de resonancia al capturar, sedimentación, tejido LLM vía `pluma-llm`, migración V1V3 → V4 con backup.
- Compartir agora/libp2p intacto (19 tests + 4 e2e).
### Pendiente
### Pendiente (fase 2)
- Sincronización bidireccional / resolución de conflictos entre cuadernos (hoy es import unidireccional de sobres).
- Transferir física temporal opcional al compartir (hoy el contenido nace fresco en el receptor — decisión de diseño, no bug).
- Endurecimiento de la malla DHT en WAN real (probado en localhost/LAN).
- **Captura global**: hotkey de mirada → popup de captura; puente chasqui (correo → nudo con `origen`); estampa de pacha/workspace. El modelo ya trae el campo `origen`.
- Unificar la captura rápida de la barra de pata (hoy store propio `pata-quick.json`) con el cuaderno de khipu.
- Resonancia entre cuerdas (sugerir fusión de cuerdas gemelas).
- Sincronización bidireccional / conflictos entre cuadernos (hoy import unidireccional).
## Consideraciones
- **No es un sistema de "todo"** — no hay due-dates ni recordatorios; es un cuaderno con física propia.
- El decay es transparente: cada nota expone su masa actual; el usuario decide si la salva.
- La gravedad es local y no transferible: compartir mueve el contenido, no la atención.
- **Los TODOs son nudos** — expresión mental como cualquier otra; sin due-dates ni recordatorios.
- **Nada se borra solo**: el sedimento es reposo, no papelera. Borrar es siempre un gesto explícito.
- La cuerda es estructura, no contenido: desatarla suelta los nudos, no los pierde.
+57 -11
View File
@@ -1,8 +1,19 @@
# khipu
> `khipu` (Quechua: knotted cord recordkeeping). Notes with temporal gravity.
> `khipu` (Quechua: knotted cord recordkeeping). The resonance notebook: ideas reinforce each other instead of being forgotten.
Quick-note capture where forgetting is part of the model: each note has a mass that decays with time and reinforces with each access. What's recurrent stays visible; what isn't touched fades until it falls off the horizon.
A place to dump loose ideas in any mental state — thoughts, TODOs, quotes — where structure **emerges from recurrence** instead of being imposed up front. The inverse of a folder-based note manager, and the inverse of the old khipu (which forgot by decay): here every idea that comes back **reinforces** the ones already there. The goal is closing brain leaks: the idea that slips away and gets re-thought from scratch.
## The model: knots, cords, weaving
- **Knot (nudo)** — the capture unit. Type, Enter, done: zero organizing in the moment. A knot can be marked pending/done (TODOs without due-dates) and carries an origin stamp when it comes from another app (phase 2: chasqui, pata, pacha).
- **Resonance** — on capture, khipu finds affine previous knots (embeddings) and shows them right there: *"this resonates with…"*. One click ties the new knot to the neighbor's cord, or spins a new cord from both. That instant is the leak being closed.
- **Cord (cuerda)** — a recurrent idea. Never created ahead of time: it's born when two knots resonate. It's christened with a name proposed from the content (editable). Its **mass** grows with each knot; its **glow**, with how recently it last grew. Nothing decays: what never resonates **settles as sediment** (down below, searchable, out of the way).
- **Weave (tejido)** — when a cord fattens but the idea stays diffuse, «tejer» asks the LLM for a framing of its knots: *what keeps repeating, what's already clear, what's left to decide*. The result is an **editable** synthesis-knot at the end of the cord (diamond ◈); re-weaving replaces it. This is the function khipu exists for — the canonical case was the idea of khipu itself.
## The window is a khipu
Capture always ready on top; a strip of fresh loose knots; and the canvas: a beam from which the cords hang ordered by mass (fattest on the left), knots in chronology. Pending = hollow ring, done = faint fill, weave = diamond. Click a knot to open the editor (right overlay); click a cord's head to open its card (weave / rename / untie). `☰ nudos` opens the searchable drawer; sediment lives there. `Esc` closes in cascade. Pan by drag or wheel.
## Install
@@ -10,21 +21,56 @@ Quick-note capture where forgetting is part of the model: each note has a mass t
cargo run --release -p khipu-app
```
## Compatibility
### Real embeddings (true semantic resonance)
- **Linux / macOS / Windows** — Llimphi UI (Wayland/X11/Win32 via `winit`).
- Local persistence in `$XDG_DATA_HOME/khipu/`.
```sh
cargo run -p rimay-verbo-daemon-bin -- --provider fastembed # listens on $XDG_RUNTIME_DIR/verbo.sock
cargo run --release -p khipu-app # auto-detects it on startup
```
Without the daemon, khipu falls back to the 16d trigram embedder — deterministic and offline. If the vector space changes between two launches, vectors are recomputed automatically.
### Weaving (LLM)
The backend comes from `pluma-llm::from_env()`: `ANTHROPIC_API_KEY` / `GEMINI_API_KEY` / `DEEPSEEK_API_KEY` / `COHERE_API_KEY` / Ollama, or an explicit `PLUMA_LLM_BACKEND`. **Without credentials it falls back to Mock** — the button always answers, even if it's a test echo.
## Persistence and migration
`$XDG_DATA_HOME/khipu/notes.bin` (postcard, V4 format: knots + cords). Notebooks from the old khipu (V1V3, the gravity map) **migrate automatically** on open: notes keep id and content, are born loose (cords get tied from then on), and the original file is backed up as `notes.v3.bak`.
## Crates
| Crate | Role |
|---|---|
| [`khipu-core`](khipu-core/README.md) | Note model + store; no UI. |
| [`khipu-gravity`](khipu-gravity/README.md) | Mass/decay algorithm; reinforcement on access. |
| [`khipu-app`](khipu-app/README.md) | Llimphi UI over the core. |
| [`khipu-core`](khipu-core/README.md) | Knots + cords + link graph + name proposal; no UI. |
| [`khipu-gravity`](khipu-gravity/README.md) | The pure physics: semantic field (resonance), cord mass/freshness, and the temporal decay (no longer used by the app — consumed by pata and iniy-derive). |
| `khipu-share` | Note envelopes, Ed25519-signed and content-addressed (BLAKE3) over agora; TCP/LAN transport + UDP discovery + encrypted identity. |
| `khipu-brahman` | Envelope transport over libp2p (BrahmanNet): encrypted stream + DHT discovery. |
| [`khipu-app`](khipu-app/README.md) | Llimphi UI: capture, cord canvas, resonance, weaving. |
## Sharing (agora)
Unchanged at its core from the old khipu — what travels is the **content** (title, body, tags), never the local structure: imported notes are born loose and fresh; cords are the attention of whoever tied them and don't transfer. `export` seals a signed envelope (filtered by the search box if non-empty); `import` verifies signature + hash, dedups by title and marks provenance `de:<author>`. `publicar`/`recibir`: TCP + UDP beacon on LAN, and libp2p (Noise over BrahmanNet, Circuit Relay v2 + DCUtR + AutoNAT, Kademlia DHT) for WAN — `KHIPU_BIND`, `KHIPU_RELAY`, `KHIPU_BOOTSTRAP`, `KHIPU_PASSPHRASE` as always. The identity lives encrypted (Argon2id + ChaCha20-Poly1305) in `<data>/keys/`.
## Status (2026-07-13)
### Done
- **Full redesign**: the physics was inverted (resonance instead of forgetting) and the gravity map died as the interface. Root view = capture + cords.
- `khipu-core`: knot/cord model (`Note.cuerda/estado/origen`, `Cuerda.tejido`), store with tie/untie/loose, name proposal.
- `khipu-gravity`: `masa_cuerda` + `frescura` (pure); temporal decay stays for its other consumers.
- `khipu-app`: cord canvas (vello painter + hit-test + pan), resonance panel on capture, sedimentation, LLM weaving via `pluma-llm`, V1V3 → V4 migration with backup.
- Sharing via agora/libp2p intact (19 tests + 4 e2e).
### Pending (phase 2)
- **Global capture**: mirada hotkey → capture popup; chasqui bridge (mail → knot with `origen`); pacha/workspace stamp. The model already carries the `origen` field.
- Unify pata's quick-capture bar (today its own `pata-quick.json` store) with the khipu notebook.
- Cord-to-cord resonance (suggest merging twin cords).
- Bidirectional sync / conflict resolution between notebooks (today import is unidirectional).
## Considerations
- **It's not a "todo" system** — no due dates, no reminders; it's a notebook with its own physics.
- Decay is transparent: each note shows its current mass; the user decides whether to save it.
- Plays well with the [agora](../../03_ukupacha/agora/README.md) network: notes can be shared without losing their local gravity.
- **TODOs are knots** — mental expression like any other; no due dates, no reminders.
- **Nothing deletes itself**: sediment is rest, not a trash can. Deleting is always an explicit gesture.
- The cord is structure, not content: untying it frees the knots, it doesn't lose them.
+10 -9
View File
@@ -2,9 +2,9 @@
# khipu
> `khipu` (runa-simi: watasqa wask'akuna yuyanapaq). Qillqakuna pacha tinkuywan.
> `khipu` (runa-simi: watasqa wask'akuna yuyanapaq). Yuyaykunaq tinkunan qillqana: yuyaykuna purapura kallpachanakun, manan qunqakunchu.
Usqhay nota hap'iy. Qunqaytaqa modelopa ukhunpi: sapanka nota llasayuq, pachawan miran, sapa kuti rikuptin kallpachayuq. Sapa kuti rikuq nota qhipanqa; mana takyaqkuna chinkanqaku, ñawi hawamanta wikch'urunqakama.
Yuyaykunata usqhaylla wikch'uy, ima sunqu-kaypipas. Musuq yuyay ñawpaq yuyaykunawan tinkuptin, khipu rikuchisunki — chaymanta **wask'a** (cuerda) paqarin: kutimuq yuyay chayta wiñachin, llasachan. Manan imapas chinkanchu: mana tinkuq yuyaykuna allpaman tiyaykun (maskhaypi tarikullanqapuni). Wask'a wirayaptin, «away» (tejido) LLM-wan huk qhawariyta awan: imas kutimun, imas sut'iña, imas faltashan.
## Churay
@@ -15,18 +15,19 @@ cargo run --release -p khipu-app
## Tinkuy
- **Linux / macOS / Windows** — Llimphi UI (Wayland/X11/Win32 `winit` patapi).
- Allpa-yana waqaychay `$XDG_DATA_HOME/khipu/`-pi.
- Allpa-yana waqaychay `$XDG_DATA_HOME/khipu/`-pi (ñawpaq khipuq qillqankuna kikinmanta migrasqa, `notes.v3.bak` copiayuq).
## Crateskuna
| Crate | Ima ruwan |
|---|---|
| [`khipu-core`](khipu-core/README.md) | Nota modelo + waqaychay; mana UI. |
| [`khipu-gravity`](khipu-gravity/README.md) | Llasay/chinkay algoritmo, rikuq kallpachay. |
| [`khipu-app`](khipu-app/README.md) | Llimphi UI core patapi. |
| [`khipu-core`](khipu-core/README.md) | Nudo + wask'a modelo, waqaychay; mana UI. |
| [`khipu-gravity`](khipu-gravity/README.md) | Físicakuna: tinkuy (resonancia), wask'a llasay/musuq-kay. |
| `khipu-share` / `khipu-brahman` | Firmasqa sobrekuna agora-wan, libp2p-wan willanakuy. |
| [`khipu-app`](khipu-app/README.md) | Llimphi UI: hap'iy, wask'a lienzo, tinkuy, away. |
## Yuyaykunaq
- **Manan "todo" sistemachu** — mana fechakuna, mana willaqkuna; pi-físikayuq qillqana kanmi.
- Chinkayqa rikukullan: sapanka nota llasayninta rikuchin; runa salvanaqa rikun.
- [agora](../../03_ukupacha/agora/README.md) ayllu-redwan tinkuy atin: nota willanakuyqa mana lokal llasayninmanta hark'aqasqa.
- **TODOkunapas nudokunam** — mana fechakuna, mana willaqkuna.
- Manan imapas sapallanmanta chinkanchu: allpaqa samaymi, manan wikch'unachu.
- Wask'aqa awasqa kaymi, manan qillqachu: paskaptiyki nudokunaqa qhipakullanku.
+9 -2
View File
@@ -6,7 +6,7 @@ rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "khipu-app — cuaderno de notas sobre Llimphi: lista editable in-situ + canvas de gravedad semántica con persistencia en $XDG_DATA_HOME/khipu/."
description = "khipu-app — cuaderno de resonancia sobre Llimphi: captura + lienzo de cuerdas (ideas recurrentes con nudos cronológicos) + tejido LLM, con persistencia en $XDG_DATA_HOME/khipu/."
[[bin]]
name = "khipu-app"
@@ -23,13 +23,15 @@ khipu-share = { path = "../khipu-share" }
khipu-brahman = { path = "../khipu-brahman" }
agora-core = { workspace = true }
rimay-verbo = { workspace = true }
# El tejido: la síntesis de una cuerda la redacta el LLM que haya en el
# entorno (fachada transparente; Mock sin credenciales).
pluma-llm = { workspace = true }
tokio = { workspace = true }
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-list = { workspace = true }
llimphi-widget-text-editor = { workspace = true }
llimphi-widget-text-input = { workspace = true }
llimphi-widget-tiled = { workspace = true }
llimphi-widget-menubar = { workspace = true }
llimphi-widget-edit-menu = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
@@ -39,3 +41,8 @@ app-bus = { workspace = true }
directories = { workspace = true }
postcard = { workspace = true }
serde = { workspace = true }
# Timeline universal (#6): al capturar un nudo se emite un checkpoint cuyo raíz
# es el BLAKE3 del contenido serializado de la nota.
blake3 = { workspace = true }
willay-checkpoint = { workspace = true }
willay-emit = { workspace = true }
+11 -5
View File
@@ -1,17 +1,23 @@
# khipu-app
> UI Llimphi sobre el core de [khipu](../README.md). Binario del usuario.
*Read this in English: [README.md](README.md).*
App de escritorio: lista de notas ordenadas por masa actual, editor inline (Markdown ligero), captura rápida (`Ctrl+N`), búsqueda fuzzy. Cada redibujo recalcula masa con [`khipu-gravity`](../khipu-gravity/README.md) y muestra notas con `mass > umbral`; las que cayeron se acceden por menú "archivo".
> UI Llimphi del cuaderno de resonancia [khipu](../LEEME.md). Binario del usuario.
La ventana es un khipu: **captura** siempre lista arriba (Enter bota el nudo), franja de **sueltos** frescos, y el lienzo de **cuerdas** colgadas de la viga — grosor por masa, brillo por frescura, nudos en cronología, tejido ◈ al final. Al capturar, el panel de **resonancia** ofrece anudar con lo que ya pensaste; **tejer** (tarjeta de cuerda o menú Cuerda) pide al LLM el encuadre de la idea. Lista y editor son overlays (`☰ nudos`, click en un nudo); `Esc` cierra en cascada; `Ctrl+N` captura, `Ctrl+F` busca.
## Uso
```sh
cargo run --release -p khipu-app
# resonancia semántica real: cargo run -p rimay-verbo-daemon-bin -- --provider fastembed
# tejido con LLM real: ANTHROPIC_API_KEY=… (o Gemini/DeepSeek/Cohere/Ollama; sin nada → Mock)
```
Persistencia: `$XDG_DATA_HOME/khipu/notes.bin` (postcard V4); los cuadernos del khipu viejo migran solos con backup `notes.v3.bak`.
## Deps
- [`khipu-core`](../khipu-core/README.md), [`khipu-gravity`](../khipu-gravity/README.md)
- [`llimphi-ui`](../../../02_ruway/llimphi/) + widgets `text-editor`, `text-input`, `list`
- [`wawa-config-llimphi`](../../../shared/wawa-config-llimphi/) para preferencias compartidas
- [`khipu-core`](../khipu-core/LEEME.md), [`khipu-gravity`](../khipu-gravity/LEEME.md), `khipu-share`, `khipu-brahman`
- [`llimphi-ui`](../../../02_ruway/llimphi/) + widgets `text-editor`, `text-input`, `list`, `menubar`, `edit-menu`
- `pluma-llm` (el tejido), `rimay-verbo` (embeddings), `agora-core` (identidad), `willay-*` (timeline)
+9 -5
View File
@@ -1,17 +1,21 @@
# khipu-app
> Llimphi UI over the core of [khipu](../README.md). The user binary.
> Llimphi UI of the [khipu](../README.md) resonance notebook. The user binary.
Desktop app: list of notes sorted by current mass, inline editor (lightweight Markdown), quick capture (`Ctrl+N`), fuzzy search. Each redraw recomputes mass via [`khipu-gravity`](../khipu-gravity/README.md) and shows notes with `mass > threshold`; fallen ones are accessed via the "archive" menu.
The window is a khipu: **capture** always ready on top (Enter drops the knot), a strip of fresh **loose knots**, and the **cord canvas** hanging from the beam — thickness by mass, glow by freshness, knots in chronology, weave ◈ at the end. On capture, the **resonance** panel offers to tie with what you already thought; **weave** (cord card or the Cuerda menu) asks the LLM for the idea's framing. List and editor are overlays (`☰ nudos`, click a knot); `Esc` closes in cascade; `Ctrl+N` captures, `Ctrl+F` searches.
## Usage
```sh
cargo run --release -p khipu-app
# real semantic resonance: cargo run -p rimay-verbo-daemon-bin -- --provider fastembed
# real LLM weaving: ANTHROPIC_API_KEY=… (or Gemini/DeepSeek/Cohere/Ollama; nothing → Mock)
```
Persistence: `$XDG_DATA_HOME/khipu/notes.bin` (postcard V4); old-khipu notebooks migrate automatically with a `notes.v3.bak` backup.
## Deps
- [`khipu-core`](../khipu-core/README.md), [`khipu-gravity`](../khipu-gravity/README.md)
- [`llimphi-ui`](../../../02_ruway/llimphi/) + widgets `text-editor`, `text-input`, `list`
- [`wawa-config-llimphi`](../../../shared/wawa-config-llimphi/) for shared prefs
- [`khipu-core`](../khipu-core/README.md), [`khipu-gravity`](../khipu-gravity/README.md), `khipu-share`, `khipu-brahman`
- [`llimphi-ui`](../../../02_ruway/llimphi/) + widgets `text-editor`, `text-input`, `list`, `menubar`, `edit-menu`
- `pluma-llm` (the weave), `rimay-verbo` (embeddings), `agora-core` (identity), `willay-*` (timeline)
File diff suppressed because it is too large Load Diff
+578
View File
@@ -0,0 +1,578 @@
//! `cuerdas` — el lienzo raíz de khipu: un khipu literal.
//!
//! Una **viga** horizontal (la cuerda madre) cruza la parte alta; de ella
//! cuelgan las **cuerdas** (ideas recurrentes), ordenadas por masa — la
//! más gorda a la izquierda. A lo largo de cada cuerda, sus **nudos** en
//! orden cronológico (arriba lo primero que se pensó); el **tejido** (la
//! síntesis) se pinta como rombo. El grosor viene de cuántos nudos tiene
//! la cuerda; el brillo, de cuán reciente fue su último crecimiento
//! (`khipu_gravity::{masa_cuerda, frescura}`) — lo que crece arde, lo
//! quieto reposa, nada desaparece.
//!
//! La geometría es una proyección pura del store (nada se persiste): el
//! layout se deriva en [`layout_cuerdas`], el painter lo pinta y el
//! hit-test ([`pick`]) lo consulta. Pan horizontal con arrastre o rueda.
use khipu_core::{CuerdaId, EstadoNudo, NoteId};
use khipu_gravity::{frescura, masa_cuerda, FRESCURA_MEDIA_VIDA_SECS};
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{auto, length, percent, FlexDirection, Position, Rect, Size, Style},
AlignItems, Dimension, JustifyContent,
};
use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Circle as KurboCircle, Stroke};
use llimphi_ui::llimphi_raster::peniko::{Color, Fill};
use llimphi_ui::llimphi_text::{draw_block, TextBlock};
use llimphi_ui::{DragPhase, View};
use crate::estado::now_secs;
use crate::modelo::{Model, Msg};
use crate::panels::button;
// =====================================================================
// Geometría
// =====================================================================
/// Margen izquierdo de la primera cuerda.
const MARGEN_X: f32 = 90.0;
/// Separación horizontal entre cuerdas.
pub(crate) const ESPACIO_CUERDA: f32 = 150.0;
/// Altura de la viga (cuerda madre) dentro del lienzo.
const VIGA_Y: f32 = 34.0;
/// Donde empiezan los nudos (deja lugar al nombre bajo la viga).
const NUDOS_TOP: f32 = 84.0;
/// Espacio que se reserva al pie del lienzo.
const FONDO_PAD: f32 = 30.0;
/// Separación vertical máxima entre nudos (se comprime si no entran).
const ESPACIO_NUDO_MAX: f32 = 40.0;
/// Radio de un nudo.
const NUDO_R: f32 = 5.0;
/// Tolerancia de click sobre un nudo (px).
const TOL_NUDO: f32 = 14.0;
/// Un nudo ya proyectado a coordenadas locales del lienzo.
pub(crate) struct NudoVis {
pub(crate) id: NoteId,
pub(crate) y: f32,
pub(crate) estado: EstadoNudo,
pub(crate) es_tejido: bool,
pub(crate) titulo: String,
}
/// Una cuerda proyectada: su columna, su físico y sus nudos.
pub(crate) struct CuerdaVis {
pub(crate) id: CuerdaId,
pub(crate) x: f32,
pub(crate) nombre: String,
/// Grosor de trazo (deriva de la masa — cantidad de nudos).
pub(crate) grosor: f32,
/// Alpha base (deriva de la frescura del último crecimiento).
pub(crate) brillo: f32,
pub(crate) color: Color,
pub(crate) nudos: Vec<NudoVis>,
}
/// Proyecta el store al lienzo: cuerdas ordenadas por masa descendente
/// (la más gorda a la izquierda, desempate por id — estable), nudos en
/// cronología descendente hacia abajo, tejido al final. Determinista.
pub(crate) fn layout_cuerdas(model: &Model, h: f32, now: u64) -> Vec<CuerdaVis> {
let theme = &model.theme;
let mut cuerdas: Vec<(CuerdaId, usize, u64)> = model
.store
.cuerdas()
.map(|c| {
let nudos = model.store.nudos_de(c.id);
let ultimo = nudos.iter().map(|n| n.created_at).max().unwrap_or(c.created_at);
(c.id, nudos.len(), ultimo)
})
.collect();
cuerdas.sort_by(|a, b| {
masa_cuerda(b.1)
.partial_cmp(&masa_cuerda(a.1))
.unwrap_or(core::cmp::Ordering::Equal)
.then(a.0.cmp(&b.0))
});
cuerdas
.into_iter()
.enumerate()
.map(|(i, (cid, n_nudos, ultimo))| {
let x = MARGEN_X + i as f32 * ESPACIO_CUERDA - model.pan_x;
let cuerda = model.store.cuerda(cid).expect("recién listada");
let nudos = model.store.nudos_de(cid);
// El tejido se pinta al final aunque su fecha sea vieja:
// es el remate de la cuerda, no un nudo más de la cronología.
let (mut cuerpo, tejido): (Vec<_>, Vec<_>) =
nudos.into_iter().partition(|n| cuerda.tejido != Some(n.id));
cuerpo.extend(tejido);
let total = cuerpo.len();
let espacio = if total <= 1 {
ESPACIO_NUDO_MAX
} else {
((h - NUDOS_TOP - FONDO_PAD) / (total as f32 - 1.0)).clamp(14.0, ESPACIO_NUDO_MAX)
};
let nudos_vis: Vec<NudoVis> = cuerpo
.iter()
.enumerate()
.map(|(j, n)| NudoVis {
id: n.id,
y: NUDOS_TOP + j as f32 * espacio,
estado: n.estado,
es_tejido: cuerda.tejido == Some(n.id),
titulo: short_label(&n.title),
})
.collect();
CuerdaVis {
id: cid,
x,
nombre: cuerda.nombre.clone(),
grosor: 1.4 + masa_cuerda(n_nudos) * 1.5,
brillo: 0.45 + 0.55 * frescura(ultimo, now, FRESCURA_MEDIA_VIDA_SECS),
color: color_cuerda(cid, *theme),
nudos: nudos_vis,
}
})
.collect()
}
/// Qué hay bajo un click en el lienzo, en coordenadas locales.
pub(crate) enum Pick {
Nudo(NoteId, CuerdaId),
Cabeza(CuerdaId),
Nada,
}
/// Resuelve el click: primero nudos (radio de tolerancia), después la
/// cabeza de la cuerda (nombre + tramo sobre los nudos), después nada.
pub(crate) fn pick(model: &Model, lx: f32, ly: f32, h: f32) -> Pick {
let now = now_secs();
let layout = layout_cuerdas(model, h, now);
for c in &layout {
for n in &c.nudos {
let d2 = (lx - c.x).powi(2) + (ly - n.y).powi(2);
if d2 <= TOL_NUDO * TOL_NUDO {
return Pick::Nudo(n.id, c.id);
}
}
}
for c in &layout {
if (lx - c.x).abs() < ESPACIO_CUERDA * 0.45 && ly > VIGA_Y - 12.0 && ly < NUDOS_TOP {
return Pick::Cabeza(c.id);
}
}
Pick::Nada
}
/// Máximo pan horizontal: que la última cuerda pueda acercarse al margen.
pub(crate) fn max_pan(model: &Model, w: f32) -> f32 {
let n = model.store.cuerdas_len() as f32;
(MARGEN_X + n * ESPACIO_CUERDA - w * 0.6).max(0.0)
}
// =====================================================================
// El panel del lienzo
// =====================================================================
/// El lienzo de cuerdas: canvas `paint_with` + pan por arrastre/rueda +
/// click con hit-test. `injected` son overlays hijos (tarjeta de cuerda,
/// etc.) que viajan encima del pintado.
pub(crate) fn cuerdas_panel(model: &Model, injected: Vec<View<Msg>>) -> View<Msg> {
let theme = model.theme;
let now = now_secs();
// La proyección se materializa aquí (h con el último tamaño conocido)
// para viajar plana dentro de la closure de pintura.
let layout = layout_cuerdas(model, model.canvas_size.1, now);
let sel_nota = model.selected;
let sel_cuerda = model.cuerda_sel;
let vacio = model.store.cuerdas_len() == 0;
let canvas = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel_alt)
.paint_with(move |scene, ts, rect| {
paint_cuerdas(scene, ts, rect, &layout, sel_nota, sel_cuerda, vacio, theme);
})
.draggable_at(|phase, dx, _dy, _lx, _ly| match phase {
DragPhase::Move => Some(Msg::CuerdasPan(-dx)),
DragPhase::End => None,
})
.on_scroll(|_dx, dy| Some(Msg::CuerdasPan(dy * 36.0)))
.on_click_at(|lx, ly, w, h| Some(Msg::CuerdasClick(lx, ly, w, h)))
.children(injected);
View::new(Style {
size: Size {
width: Dimension::auto(),
height: percent(1.0_f32),
},
flex_grow: 1.0,
flex_basis: length(0.0_f32),
min_size: Size {
width: length(0.0_f32),
height: length(0.0_f32),
},
padding: Rect {
left: length(4.0_f32),
right: length(4.0_f32),
top: length(4.0_f32),
bottom: length(4.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel)
.children(vec![canvas])
}
#[allow(clippy::too_many_arguments)]
fn paint_cuerdas(
scene: &mut llimphi_ui::llimphi_raster::vello::Scene,
ts: &mut llimphi_ui::llimphi_text::Typesetter,
rect: llimphi_ui::PaintRect,
layout: &[CuerdaVis],
sel_nota: Option<NoteId>,
sel_cuerda: Option<CuerdaId>,
vacio: bool,
theme: Theme,
) {
if rect.w <= 0.0 || rect.h <= 0.0 {
return;
}
let sx = |x: f32| (rect.x + x) as f64;
let sy = |y: f32| (rect.y + y) as f64;
// La viga: la cuerda madre de la que cuelga todo.
let mut viga = BezPath::new();
viga.move_to((sx(0.0), sy(VIGA_Y)));
viga.line_to((sx(rect.w), sy(VIGA_Y)));
scene.stroke(
&Stroke::new(3.0),
Affine::IDENTITY,
with_alpha(theme.fg_muted, 0.45),
None,
&viga,
);
if vacio {
draw_block(
scene,
ts,
&TextBlock::simple(
"las cuerdas nacen cuando dos nudos resuenan — bota una idea arriba",
13.0,
with_alpha(theme.fg_muted, 0.8),
(sx(rect.w * 0.5 - 240.0), sy(rect.h * 0.42)),
),
);
return;
}
for c in layout {
// Culling: fuera del lienzo no se pinta.
if c.x < -ESPACIO_CUERDA || c.x > rect.w + ESPACIO_CUERDA {
continue;
}
let seleccionada = sel_cuerda == Some(c.id);
let brillo = if seleccionada { 1.0 } else { c.brillo };
let col = with_alpha(c.color, brillo);
// Amarre a la viga.
let amarre = KurboCircle::new((sx(c.x), sy(VIGA_Y)), 3.6);
scene.fill(Fill::NonZero, Affine::IDENTITY, col, None, &amarre);
if seleccionada {
let ring = KurboCircle::new((sx(c.x), sy(VIGA_Y)), 6.5);
scene.stroke(&Stroke::new(1.6), Affine::IDENTITY, theme.accent, None, &ring);
}
// El nombre, colgado bajo la viga.
let size = 11.5_f32;
let est_w = c.nombre.chars().count() as f64 * size as f64 * 0.52;
let name_col = if seleccionada {
theme.accent
} else {
with_alpha(theme.fg_text, (brillo + 0.15).clamp(0.0, 1.0))
};
draw_block(
scene,
ts,
&TextBlock::simple(
&c.nombre,
size,
name_col,
(sx(c.x) - est_w * 0.5, sy(VIGA_Y + 12.0)),
),
);
// La cuerda: de la viga al último nudo (o un colgajo corto si
// está vacía).
let y_fin = c.nudos.last().map(|n| n.y + 14.0).unwrap_or(NUDOS_TOP);
let mut linea = BezPath::new();
linea.move_to((sx(c.x), sy(VIGA_Y)));
linea.line_to((sx(c.x), sy(y_fin)));
scene.stroke(
&Stroke::new(c.grosor as f64),
Affine::IDENTITY,
with_alpha(c.color, brillo * 0.8),
None,
&linea,
);
// Los nudos.
for n in &c.nudos {
let (px, py) = (sx(c.x), sy(n.y));
if n.es_tejido {
// El tejido: rombo lleno, el remate brillante de la cuerda.
let r = (NUDO_R + 2.5) as f64;
let mut rombo = BezPath::new();
rombo.move_to((px, py - r));
rombo.line_to((px + r, py));
rombo.line_to((px, py + r));
rombo.line_to((px - r, py));
rombo.close_path();
scene.fill(Fill::NonZero, Affine::IDENTITY, theme.accent, None, &rombo);
} else {
let circle = KurboCircle::new((px, py), NUDO_R as f64);
match n.estado {
EstadoNudo::Normal => {
scene.fill(Fill::NonZero, Affine::IDENTITY, col, None, &circle);
}
EstadoNudo::Pendiente => {
// Anillo hueco: un nudo por hacer.
scene.stroke(&Stroke::new(1.8), Affine::IDENTITY, col, None, &circle);
}
EstadoNudo::Hecho => {
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
with_alpha(c.color, brillo * 0.35),
None,
&circle,
);
}
}
}
if sel_nota == Some(n.id) {
let ring = KurboCircle::new((px, py), (NUDO_R + 4.0) as f64);
scene.stroke(&Stroke::new(1.8), Affine::IDENTITY, theme.accent, None, &ring);
}
// Etiquetas sólo en la cuerda seleccionada (o el nudo elegido):
// de lejos el khipu se lee por forma, de cerca por texto.
if seleccionada || sel_nota == Some(n.id) {
let lbl_col = with_alpha(theme.fg_text, (brillo + 0.2).clamp(0.0, 1.0));
draw_block(
scene,
ts,
&TextBlock::simple(&n.titulo, 10.0, lbl_col, (px + 10.0, py - 6.0)),
);
}
}
}
}
// =====================================================================
// Overlays de flujo (compartidos con app.rs)
// =====================================================================
/// Envuelve `child` como cajón absoluto pegado al borde izquierdo, alto
/// completo con un margen. El lienzo de fondo sigue recibiendo eventos en
/// el resto de la ventana; sólo los clicks sobre el cajón los come él.
pub(crate) fn overlay_left(child: View<Msg>, width: f32) -> View<Msg> {
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(8.0_f32),
top: length(8.0_f32),
bottom: length(8.0_f32),
right: auto(),
},
size: Size {
width: length(width),
height: auto(),
},
flex_direction: FlexDirection::Column,
..Default::default()
})
.children(vec![child])
}
/// Columna interna del editor: barra de cierre (× ⇒ deselecciona) arriba +
/// el editor abajo, sobre `bg_panel`.
pub(crate) fn editor_shell(child: View<Msg>, theme: &Theme) -> View<Msg> {
let close = button("× cerrar", theme.bg_button, theme.fg_muted, Msg::Deselect);
let close_row = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(28.0_f32),
},
flex_shrink: 0.0,
justify_content: Some(JustifyContent::End),
align_items: Some(AlignItems::Center),
padding: Rect {
left: length(6.0_f32),
right: length(6.0_f32),
top: length(2.0_f32),
bottom: length(2.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel_alt)
.children(vec![close]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel)
.children(vec![close_row, child])
}
/// Envuelve `child` como panel absoluto pegado al borde derecho, alto
/// completo, con barra de cierre — el editor del nudo abierto.
pub(crate) fn overlay_right(child: View<Msg>, width: f32, theme: &Theme) -> View<Msg> {
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: auto(),
top: length(8.0_f32),
bottom: length(8.0_f32),
right: length(8.0_f32),
},
size: Size {
width: length(width),
height: auto(),
},
flex_direction: FlexDirection::Column,
..Default::default()
})
.children(vec![editor_shell(child, theme)])
}
// =====================================================================
// Color y texto
// =====================================================================
pub(crate) fn with_alpha(c: Color, alpha: f32) -> Color {
let [r, g, b, _] = c.components;
Color::new([r, g, b, alpha])
}
/// Color estable de una cuerda: matices por ángulo áureo sobre el hue del
/// accent — determinista por id, sin paleta que se agote.
pub(crate) fn color_cuerda(id: CuerdaId, theme: Theme) -> Color {
rotate_hue(theme.accent, (id as f32 * 0.381_966) % 1.0)
}
pub(crate) fn rotate_hue(c: Color, dh: f32) -> Color {
// RGB → HSV → rota H → RGB. Aproximación, alpha fijo.
let [r, g, b, a] = c.components;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let v = max;
let s = if max <= 0.0 { 0.0 } else { (max - min) / max };
let h = if (max - min).abs() < 1e-6 {
0.0
} else if max == r {
((g - b) / (max - min)) % 6.0
} else if max == g {
(b - r) / (max - min) + 2.0
} else {
(r - g) / (max - min) + 4.0
};
let h2 = ((h / 6.0) + dh).rem_euclid(1.0) * 6.0;
let c2 = v * s;
let x = c2 * (1.0 - ((h2 % 2.0) - 1.0).abs());
let (r2, g2, b2) = match h2 as i32 {
0 => (c2, x, 0.0),
1 => (x, c2, 0.0),
2 => (0.0, c2, x),
3 => (0.0, x, c2),
4 => (x, 0.0, c2),
_ => (c2, 0.0, x),
};
let m = v - c2;
Color::new([r2 + m, g2 + m, b2 + m, a])
}
pub(crate) fn short_label(s: &str) -> String {
let s = if s.trim().is_empty() { "(sin título)" } else { s };
let mut out: String = s.chars().take(26).collect();
if s.chars().count() > 26 {
out.push('…');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::modelo::{Embedder, Model};
/// Modelo con dos cuerdas: «gorda» (3 nudos, vieja) y «flaca»
/// (1 nudo, recién crecida), más un suelto.
fn modelo() -> (Model, CuerdaId, CuerdaId) {
let mut m = Model::blank(Embedder::Local);
let gorda = m.store.crear_cuerda("gorda", 0);
for (i, t) in ["a", "b", "c"].iter().enumerate() {
let id = m.store.create(*t, "", vec![], 100 + i as u64);
m.order.push(id);
m.store.anudar(id, gorda);
}
let flaca = m.store.crear_cuerda("flaca", 0);
let id = m.store.create("d", "", vec![], 900);
m.order.push(id);
m.store.anudar(id, flaca);
let suelto = m.store.create("suelto", "", vec![], 950);
m.order.push(suelto);
(m, gorda, flaca)
}
#[test]
fn layout_ordena_por_masa_y_cronologia() {
let (m, gorda, flaca) = modelo();
let l = layout_cuerdas(&m, 600.0, 1_000);
assert_eq!(l.len(), 2);
// La gorda a la izquierda (más masa), la flaca después.
assert_eq!(l[0].id, gorda);
assert_eq!(l[1].id, flaca);
assert!(l[0].x < l[1].x);
assert!(l[0].grosor > l[1].grosor);
// La flaca creció más recién → arde más.
assert!(l[1].brillo > l[0].brillo);
// Nudos en cronología descendente hacia abajo.
let ys: Vec<f32> = l[0].nudos.iter().map(|n| n.y).collect();
assert_eq!(l[0].nudos.len(), 3);
assert!(ys.windows(2).all(|w| w[0] < w[1]));
// Los sueltos no cuelgan de la viga.
assert!(l.iter().all(|c| c.nudos.iter().all(|n| n.titulo != "suelto")));
}
#[test]
fn pick_encuentra_nudo_cabeza_y_nada() {
let (m, gorda, _) = modelo();
let l = layout_cuerdas(&m, 600.0, 1_000);
let (cx, ny) = (l[0].x, l[0].nudos[0].y);
assert!(matches!(pick(&m, cx, ny, 600.0), Pick::Nudo(_, c) if c == gorda));
// Sobre el nombre, entre viga y nudos: la cabeza.
assert!(matches!(pick(&m, cx, 50.0, 600.0), Pick::Cabeza(c) if c == gorda));
// Lejos de todo: nada.
assert!(matches!(pick(&m, 5_000.0, 300.0, 600.0), Pick::Nada));
}
#[test]
fn max_pan_crece_con_las_cuerdas() {
let (m, ..) = modelo();
// Dos cuerdas entran holgadas en 1280 px: sin pan.
assert_eq!(max_pan(&m, 1280.0), 0.0);
// En una ventana angosta sí hay tela que correr.
assert!(max_pan(&m, 200.0) > 0.0);
}
}
+664
View File
@@ -0,0 +1,664 @@
//! `estado` — persistencia, embeddings y helpers de modelo.
//!
//! Cubre:
//! - Formatos de serialización (`PersistedState` V4 + sombras de los
//! formatos viejos) y su migración.
//! - `load_state` / `persist`: I/O de disco con postcard.
//! - `from_state` / `seeded_model`: inicialización del `Model`.
//! - `embed_now` / `schedule_embedding`: vectores semánticos.
//! - Helpers de modelo: `select`, `deselect`, `commit_edits`,
//! `resonancia_de`, `es_sedimento`, `now_secs`, `parse_tags`,
//! `unlock_identity`, `start_unlock`, `khipu_dir`, `data_file_path`.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use agora_core::Keypair;
use directories::ProjectDirs;
use khipu_core::{Note, NoteId, NoteStore};
use llimphi_widget_text_editor::EditorState;
use serde::{Deserialize, Serialize};
use crate::modelo::{Embedder, Focus, Model, Msg, RESONANCIA_MAX, SEDIMENTO_SECS};
use llimphi_ui::Handle;
// =====================================================================
// Formatos de persistencia
// =====================================================================
/// Formato vigente (V4): el store trae nudos **y cuerdas**; las notas ya
/// no tienen posición de mapa (el lienzo de cuerdas se deriva, no se
/// persiste) ni las regiones existen.
#[derive(Serialize, Deserialize)]
pub(crate) struct PersistedState {
pub(crate) store: NoteStore,
pub(crate) embeddings: Vec<(NoteId, Vec<f32>)>,
pub(crate) order: Vec<NoteId>,
/// Etiqueta del espacio vectorial con que se guardaron los embeddings.
pub(crate) model: String,
}
// --- Sombras de los formatos viejos (postcard es posicional: hay que
// --- replicar la forma exacta con que se escribieron). El `Note` viejo
// --- tenía `pos` y no tenía cuerda/estado/origen; el store no tenía
// --- cuerdas; el V3 arrastraba las regiones del mapa.
#[derive(Deserialize)]
struct NoteVieja {
id: NoteId,
title: String,
body: String,
tags: Vec<String>,
created_at: u64,
updated_at: u64,
last_access: u64,
mass: f32,
/// La posición ancla del mapa muerto. Se descarta al migrar.
#[allow(dead_code)]
pos: Option<(f32, f32)>,
}
#[derive(Deserialize)]
struct NoteStoreViejo {
notes: BTreeMap<NoteId, NoteVieja>,
#[allow(dead_code)]
next_id: NoteId,
}
#[derive(Deserialize)]
#[allow(dead_code)]
struct RegionVieja {
name: String,
x: f32,
y: f32,
}
/// Formato V3: con `model` y `regions` (los topónimos del mapa muerto).
#[derive(Deserialize)]
struct PersistedV3 {
store: NoteStoreViejo,
embeddings: Vec<(NoteId, Vec<f32>)>,
order: Vec<NoteId>,
model: String,
#[allow(dead_code)]
regions: Vec<RegionVieja>,
}
/// Formato V2: como V3 sin regiones.
#[derive(Deserialize)]
struct PersistedV2 {
store: NoteStoreViejo,
embeddings: Vec<(NoteId, Vec<f32>)>,
order: Vec<NoteId>,
model: String,
}
/// Formato V1 histórico, sin `model` (previo a enchufar `verbo`).
#[derive(Deserialize)]
struct PersistedV1 {
store: NoteStoreViejo,
embeddings: Vec<(NoteId, Vec<f32>)>,
order: Vec<NoteId>,
}
/// Migra un store viejo al modelo actual: mismas identidades, sin
/// posición; todo nace suelto (las cuerdas se anudan de aquí en más).
fn migrar_store(viejo: NoteStoreViejo) -> NoteStore {
let mut store = NoteStore::new();
for (_, n) in viejo.notes {
store.insert_raw(Note {
id: n.id,
title: n.title,
body: n.body,
tags: n.tags,
created_at: n.created_at,
updated_at: n.updated_at,
last_access: n.last_access,
mass: n.mass,
cuerda: None,
estado: Default::default(),
origen: None,
});
}
store
}
// =====================================================================
// I/O de disco
// =====================================================================
/// Directorio de datos de khipu (`$XDG_DATA_HOME/khipu/`), creándolo si
/// hace falta. Raíz de `notes.bin`, `keys/` y `compartido.khipu`.
pub(crate) fn khipu_dir() -> Option<PathBuf> {
let dirs = ProjectDirs::from("org", "tawasuyu", "khipu")?;
let dir = dirs.data_dir().to_path_buf();
std::fs::create_dir_all(&dir).ok()?;
Some(dir)
}
pub(crate) fn data_file_path() -> Option<PathBuf> {
Some(khipu_dir()?.join("notes.bin"))
}
/// Copia de respaldo única antes de migrar/pisar un formato viejo. Si el
/// backup ya existe no se toca (conserva el estado pre-migración).
fn respaldar_una_vez(path: &Path, sufijo: &str) {
let bak = path.with_extension(sufijo);
if !bak.exists() {
let _ = std::fs::copy(path, &bak);
}
}
pub(crate) fn load_state(path: &PathBuf) -> Option<PersistedState> {
let bytes = std::fs::read(path).ok()?;
// Formato vigente primero. postcard es posicional (no self-describing),
// así que un archivo viejo puede "parsear" con basura — el chequeo de
// coherencia del store descarta esos falsos positivos.
if let Ok(state) = postcard::from_bytes::<PersistedState>(&bytes) {
if state.store.coherente() {
return Some(state);
}
}
// Formatos del mapa muerto: migran con backup (notes.v3.bak) para que
// ninguna migración fallida pueda costar el cuaderno.
if let Ok(v3) = postcard::from_bytes::<PersistedV3>(&bytes) {
respaldar_una_vez(path, "v3.bak");
return Some(PersistedState {
store: migrar_store(v3.store),
embeddings: v3.embeddings,
order: v3.order,
model: v3.model,
});
}
if let Ok(v2) = postcard::from_bytes::<PersistedV2>(&bytes) {
respaldar_una_vez(path, "v3.bak");
return Some(PersistedState {
store: migrar_store(v2.store),
embeddings: v2.embeddings,
order: v2.order,
model: v2.model,
});
}
let v1: PersistedV1 = postcard::from_bytes(&bytes).ok()?;
respaldar_una_vez(path, "v3.bak");
Some(PersistedState {
store: migrar_store(v1.store),
embeddings: v1.embeddings,
order: v1.order,
model: String::new(),
})
}
pub(crate) fn persist(model: &Model) {
let Some(path) = model.data_path.as_ref() else {
return;
};
let state = PersistedState {
store: model.store.clone(),
embeddings: model
.field
.iter()
.map(|(id, v)| (id, v.to_vec()))
.collect(),
order: model.order.clone(),
model: model.embedder.label(),
};
if let Ok(bytes) = postcard::to_allocvec(&state) {
let tmp = path.with_extension("bin.tmp");
if std::fs::write(&tmp, &bytes).is_ok() {
let _ = std::fs::rename(&tmp, path);
}
}
}
/// Emite al centro de eventos (willay) un checkpoint de una nota — la entrada de
/// khipu en la línea de tiempo universal (PLAN-FRONTERA #6). La raíz es el BLAKE3
/// del contenido serializado de la nota (`postcard` determinista sobre un `Note`,
/// que es un `BTreeMap`-free struct estable). Se llama en límites discretos
/// (capturar un nudo), **no** en `persist` — que corre por tecla. **Nunca falla:**
/// `emitir_silencioso` no-opea sin el daemon willay.
pub(crate) fn emitir_checkpoint_nota(model: &Model, id: NoteId) {
let Some(note) = model.store.get(id) else {
return;
};
let Ok(bytes) = postcard::to_allocvec(note) else {
return;
};
let etiqueta = if note.title.trim().is_empty() {
"nudo sin título".to_string()
} else {
note.title.clone()
};
let cp = willay_checkpoint::Checkpoint {
app: "khipu".to_string(),
cosa: format!("nota:{id}"),
etiqueta,
raiz: *blake3::hash(&bytes).as_bytes(),
padre: None,
ts_usec: willay_emit::ahora_usec(),
autor: willay_checkpoint::Autor::Humano,
};
willay_emit::emitir_silencioso(&cp.a_evento());
}
// =====================================================================
// Inicialización del Model
// =====================================================================
pub(crate) fn from_state(state: PersistedState, embedder: Embedder) -> Model {
// ¿Los vectores guardados son del mismo espacio que el embebedor
// activo? Si cambió el modelo o la dimensión, son incomparables:
// se descartan y se recalcula todo el cuaderno.
let same_space = !state.model.is_empty() && state.model == embedder.label();
let mut model = Model::blank(embedder);
model.store = state.store;
model.order = state.order;
if same_space {
let restored: std::collections::HashSet<NoteId> =
state.embeddings.iter().map(|(id, _)| *id).collect();
for (id, v) in &state.embeddings {
if !v.is_empty() {
model.field.insert_etiquetado(*id, v.clone(), &model.embedder.label());
}
}
// Notas sin vector persistido: recalcular sólo esas.
let missing: Vec<NoteId> = model
.order
.iter()
.copied()
.filter(|id| !restored.contains(id))
.collect();
for id in missing {
embed_now(&mut model, id);
}
} else {
let ids: Vec<NoteId> = model.order.clone();
for id in ids {
embed_now(&mut model, id);
}
}
model
}
/// El cuaderno demo de un khipu recién nacido: la cuerda «khipu» — la
/// idea que originó la app, contada en sus propios nudos — más dos
/// sueltos esperando resonar. Meta y didáctico a la vez: se ve una
/// cuerda con masa, un pendiente, y material listo para tejer.
pub(crate) fn seeded_model(embedder: Embedder) -> Model {
let mut model = Model::blank(embedder);
let now = now_secs();
let dia = 24 * 3600;
let cuerda = model.store.crear_cuerda("khipu", now.saturating_sub(9 * dia));
let nudos_cuerda: [(&str, u64); 3] = [
(
"quiero un lugar donde botar mis ideas sueltas, en cualquier estado mental",
now.saturating_sub(9 * dia),
),
(
"que las ideas se refuercen entre sí al volver, en vez de decaer por no leerlas",
now.saturating_sub(5 * dia),
),
(
"no otro keep u obsidian cuadrado: la estructura tiene que emerger de la recurrencia",
now.saturating_sub(2 * dia),
),
];
for (texto, ts) in nudos_cuerda {
let id = model.store.create(texto, "", vec![], ts);
model.order.push(id);
model.store.anudar(id, cuerda);
embed_now(&mut model, id);
}
let sueltos: [(&str, u64); 2] = [
("probar el tejido de una cuerda con el daemon de verbo corriendo", now.saturating_sub(dia)),
("la barra de captura de pata podría botar nudos aquí (fase 2)", now),
];
for (texto, ts) in sueltos {
let id = model.store.create(texto, "", vec![], ts);
model.order.push(id);
embed_now(&mut model, id);
}
// El primero de los sueltos es un pendiente — muestra el TODO.
if let Some(primero) = model.store.sueltos().last().map(|n| n.id) {
if let Some(n) = model.store.get_mut(primero) {
n.estado = khipu_core::EstadoNudo::Pendiente;
}
}
model
}
// =====================================================================
// Embeddings
// =====================================================================
// El embedder local de fallback (hash trigram → vector) vive en el core
// `khipu-gravity` como `local_embed`, junto a los vectores que alimenta (Regla
// 2). `modelo::Embedder::Local` lo llama directamente desde ahí.
/// Versión síncrona para el arranque (seed y migración de formato):
/// calcula el vector en línea y lo inserta. En init todavía no hay nada
/// que repintar, así que bloquear un instante es lo correcto.
pub(crate) fn embed_now(model: &mut Model, id: NoteId) {
let Some(note) = model.store.get(id) else {
return;
};
let combined = format!("{} {}", note.title, note.body);
let v = model.embedder.embed_blocking(&combined);
model.field.insert_etiquetado(id, v, &model.embedder.label());
}
/// Pide el embedding de `id` en segundo plano. Asigna una secuencia
/// nueva, la marca como vigente, y dispara un worker (`Handle::spawn`)
/// que al terminar reentra al `update` con [`Msg::EmbeddingReady`]. Así
/// el `block_on` del arm remoto nunca corre en el hilo de UI.
pub(crate) fn schedule_embedding(model: &mut Model, id: NoteId, h: &Handle<Msg>) {
let Some(note) = model.store.get(id) else {
return;
};
let combined = format!("{} {}", note.title, note.body);
model.embed_seq += 1;
let seq = model.embed_seq;
model.embed_latest.insert(id, seq);
let embedder = model.embedder.clone();
h.spawn(move || {
let v = embedder.embed_blocking(&combined);
Msg::EmbeddingReady(id, seq, v)
});
}
// =====================================================================
// Resonancia y sedimento
// =====================================================================
/// Los vecinos que un nudo toca: afinidad coseno sobre el umbral del
/// espacio vectorial activo, de mayor a menor, a lo sumo
/// [`RESONANCIA_MAX`]. Excluye al propio nudo (lo hace `nearest`) y al
/// tejido de las cuerdas (una síntesis siempre "resuena" con su materia —
/// no informa).
pub(crate) fn resonancia_de(model: &Model, id: NoteId) -> Vec<(NoteId, f32)> {
let umbral = model.embedder.umbral_resonancia();
let tejidos: std::collections::BTreeSet<NoteId> =
model.store.cuerdas().filter_map(|c| c.tejido).collect();
model
.field
.nearest(id, RESONANCIA_MAX + tejidos.len())
.into_iter()
.filter(|(nid, aff)| *aff >= umbral && !tejidos.contains(nid))
.take(RESONANCIA_MAX)
.collect()
}
/// `true` si un nudo suelto ya sedimentó: sin cuerda y sin toques desde
/// hace [`SEDIMENTO_SECS`]. El sedimento no se borra ni se esconde del
/// buscador — sólo deja la franja de sueltos frescos.
pub(crate) fn es_sedimento(n: &Note, now: u64) -> bool {
n.suelto() && now.saturating_sub(n.updated_at.max(n.created_at)) > SEDIMENTO_SECS
}
/// Nombre propuesto para una cuerda que nace de estos nudos, con
/// fallback genérico si no hay señal en los títulos/etiquetas.
pub(crate) fn nombre_propuesto(model: &Model, ids: &[NoteId]) -> String {
let notas: Vec<&Note> = ids.iter().filter_map(|id| model.store.get(*id)).collect();
khipu_core::propose_cuerda_name(&notas).unwrap_or_else(|| "idea".to_string())
}
// =====================================================================
// Helpers de modelo
// =====================================================================
pub(crate) fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub(crate) fn parse_tags(raw: &str) -> Vec<String> {
raw.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect()
}
/// Carga la nota `id` en los campos del editor y foca el cuerpo.
pub(crate) fn select(model: &mut Model, id: NoteId) {
let Some(note) = model.store.get(id) else {
return;
};
model.selected = Some(id);
model.title.set_text(note.title.clone());
model.body = EditorState::default();
model.body.set_text(&note.body);
model.tags.set_text(note.tags.join(", "));
model.focus = Focus::Body;
}
/// Suelta la nota seleccionada y limpia los campos del editor.
pub(crate) fn deselect(model: &mut Model) {
model.selected = None;
model.title.set_text(String::new());
model.body = EditorState::default();
model.tags.set_text(String::new());
model.focus = Focus::None;
}
/// Sincroniza inputs/editor → store/field + persiste si cambió algo.
pub(crate) fn commit_edits(model: &mut Model, h: &Handle<Msg>) {
let Some(id) = model.selected else {
return;
};
let mut changed = false;
let new_title = model.title.text();
let new_body = model.body.text();
let new_tags = parse_tags(&model.tags.text());
let now = now_secs();
if let Some(note) = model.store.get_mut(id) {
if note.title != new_title {
note.title = new_title;
note.updated_at = now;
changed = true;
}
if note.body != new_body {
note.body = new_body;
note.updated_at = now;
changed = true;
}
if note.tags != new_tags {
note.tags = new_tags;
note.updated_at = now;
changed = true;
}
}
if changed {
// El texto ya está en el store: persistimos de inmediato para no
// perderlo. El embedding viaja a un worker y persistirá de nuevo
// cuando llegue (`Msg::EmbeddingReady`).
persist(model);
schedule_embedding(model, id, h);
}
}
/// Desbloquea (o crea, o migra) la identidad del cuaderno con `passphrase`,
/// vía [`khipu_share::identity::unlock`]. La semilla vive cifrada en
/// `<datos>/keys/`; si existe un `identidad.seed` en claro de versiones
/// viejas, se migra al keystore y se borra el claro.
pub(crate) fn unlock_identity(passphrase: &str) -> Option<Keypair> {
let dir = khipu_dir()?;
let legacy = dir.join("identidad.seed");
khipu_share::identity::unlock(&dir.join("keys"), Some(&legacy), passphrase).ok()
}
/// Arranca el prompt de passphrase y memoriza la acción a reanudar.
pub(crate) fn start_unlock(model: &mut Model, accion: Msg) {
model.unlocking = true;
model.pending = Some(Box::new(accion));
model.focus = Focus::Passphrase;
model.passphrase.clear();
model.status = Some("ingresa tu passphrase para desbloquear la identidad".into());
}
#[cfg(test)]
mod tests {
use super::*;
// Twins serializables de los formatos viejos, con la MISMA forma
// posicional con que el khipu del mapa escribía notes.bin. Si estos
// structs divergen de los `Deserialize` de arriba, la migración es
// mentira — por eso el test escribe bytes de verdad y los carga.
#[derive(serde::Serialize)]
struct NoteViejaW {
id: u64,
title: String,
body: String,
tags: Vec<String>,
created_at: u64,
updated_at: u64,
last_access: u64,
mass: f32,
pos: Option<(f32, f32)>,
}
#[derive(serde::Serialize)]
struct NoteStoreViejoW {
notes: BTreeMap<u64, NoteViejaW>,
next_id: u64,
}
#[derive(serde::Serialize)]
struct RegionViejaW {
name: String,
x: f32,
y: f32,
}
#[derive(serde::Serialize)]
struct PersistedV3W {
store: NoteStoreViejoW,
embeddings: Vec<(u64, Vec<f32>)>,
order: Vec<u64>,
model: String,
regions: Vec<RegionViejaW>,
}
fn nota_vieja(id: u64, title: &str) -> NoteViejaW {
NoteViejaW {
id,
title: title.into(),
body: format!("cuerpo de {title}"),
tags: vec!["vieja".into()],
created_at: 100 + id,
updated_at: 200 + id,
last_access: 300 + id,
mass: 0.7,
pos: Some((10.0 * id as f32, -5.0)),
}
}
#[test]
fn migra_v3_conservando_ids_y_contenido_con_backup() {
let mut notes = BTreeMap::new();
// Ids con hueco (el 2 fue borrado en su día) — deben conservarse.
notes.insert(1, nota_vieja(1, "Primera"));
notes.insert(3, nota_vieja(3, "Tercera"));
let v3 = PersistedV3W {
store: NoteStoreViejoW { notes, next_id: 4 },
embeddings: vec![(1, vec![0.5, 0.5]), (3, vec![0.1, 0.9])],
order: vec![1, 3],
model: "khipu-trigram-16d".into(),
regions: vec![RegionViejaW { name: "Zona".into(), x: 1.0, y: 2.0 }],
};
let bytes = postcard::to_allocvec(&v3).unwrap();
let dir = std::env::temp_dir().join(format!("khipu-migra-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("notes.bin");
std::fs::write(&path, &bytes).unwrap();
let state = load_state(&path).expect("el V3 migra");
assert!(state.store.coherente());
assert_eq!(state.store.len(), 2);
let n1 = state.store.get(1).unwrap();
assert_eq!(n1.title, "Primera");
assert_eq!(n1.body, "cuerpo de Primera");
assert_eq!(n1.created_at, 101);
assert!(n1.suelto()); // todo migra suelto: las cuerdas se anudan de aquí en más
assert!(state.store.get(3).is_some());
assert_eq!(state.order, vec![1, 3]);
assert_eq!(state.model, "khipu-trigram-16d");
assert_eq!(state.embeddings.len(), 2);
// El hueco no se reusa: el próximo create no pisa nada.
let mut store = state.store.clone();
let nuevo = store.create("nueva", "", vec![], 0);
assert!(nuevo >= 4);
// Backup pre-migración en su lugar.
assert!(path.with_extension("v3.bak").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn el_formato_v4_roundtripea_con_cuerdas() {
let mut store = NoteStore::new();
let a = store.create("nudo a", "", vec![], 10);
let b = store.create("nudo b", "", vec![], 20);
let c = store.crear_cuerda("idea", 20);
store.anudar(a, c);
store.anudar(b, c);
let state = PersistedState {
store,
embeddings: vec![(a, vec![1.0]), (b, vec![0.9])],
order: vec![a, b],
model: "x".into(),
};
let bytes = postcard::to_allocvec(&state).unwrap();
let dir = std::env::temp_dir().join(format!("khipu-v4-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("notes.bin");
std::fs::write(&path, &bytes).unwrap();
let cargado = load_state(&path).expect("el V4 carga directo");
assert!(cargado.store.coherente());
assert_eq!(cargado.store.cuerdas_len(), 1);
assert_eq!(cargado.store.nudos_de(c).len(), 2);
// Sin migración no hay backup.
assert!(!path.with_extension("v3.bak").exists());
let _ = std::fs::remove_dir_all(&dir);
}
/// Prueba de fuego opcional: migrar una COPIA del cuaderno real.
/// `KHIPU_MIGRA_REAL=/ruta/a/notes.bin cargo test -p khipu-app migra_el_cuaderno_real`
/// No corre en CI (sin la env var es un no-op).
#[test]
fn migra_el_cuaderno_real_si_esta() {
let Ok(origen) = std::env::var("KHIPU_MIGRA_REAL") else {
return;
};
let dir = std::env::temp_dir().join(format!("khipu-real-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("notes.bin");
std::fs::copy(&origen, &path).expect("copiar el cuaderno real");
let state = load_state(&path).expect("el cuaderno real carga o migra");
assert!(state.store.coherente());
assert!(state.store.len() > 0, "un cuaderno real trae notas");
println!(
"cuaderno real: {} notas · {} cuerdas · modelo «{}»",
state.store.len(),
state.store.cuerdas_len(),
state.model
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn es_sedimento_respeta_el_umbral_y_las_cuerdas() {
let mut store = NoteStore::new();
let viejo = store.create("viejo", "", vec![], 0);
let now = SEDIMENTO_SECS + 10;
assert!(es_sedimento(store.get(viejo).unwrap(), now));
// Un nudo anudado nunca sedimenta.
let c = store.crear_cuerda("idea", 0);
store.anudar(viejo, c);
assert!(!es_sedimento(store.get(viejo).unwrap(), now));
// Uno fresco tampoco.
let fresco = store.create("fresco", "", vec![], now);
assert!(!es_sedimento(store.get(fresco).unwrap(), now));
}
}
File diff suppressed because it is too large Load Diff
-655
View File
@@ -1,655 +0,0 @@
//! `map` — el lienzo de pensamientos: geometría de cámara, colocación
//! determinista de notas (anclaje), detección de regiones emergentes y el
//! pintado del mapa (nodos que respiran por masa, filamentos, topónimos).
//!
//! El resto de la app le pide vistas y consultas; los helpers compartidos
//! (`current_mass`, `now_secs`, `button`, `CLUSTER_THRESHOLD`) viven en la
//! raíz y se alcanzan por `crate::`.
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{auto, length, percent, FlexDirection, Position, Rect, Size, Style},
AlignItems, Dimension, JustifyContent,
};
use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Circle as KurboCircle, Stroke};
use llimphi_ui::llimphi_raster::peniko::{Color, Fill};
use llimphi_ui::llimphi_text::{draw_block, Alignment, TextBlock};
use llimphi_ui::{DragPhase, View};
use llimphi_widget_text_input::{text_input_view, TextInputPalette};
use khipu_core::NoteId;
use crate::panels::button;
use crate::{current_mass, now_secs, Focus, Model, Msg, CLUSTER_THRESHOLD};
/// Un nodo del mapa, ya resuelto a coordenadas de mundo + su masa viva.
/// Datos planos para viajar dentro de la closure de pintura.
pub(crate) struct MapNode {
id: NoteId,
/// Coordenadas de mundo (el domicilio fijo de la nota).
x: f32,
y: f32,
/// Masa "vivida" en el instante del render: enciende el brillo y el
/// tamaño. Decae con el tiempo → el mapa respira sin que toques nada.
mass: f32,
/// `false` si cayó bajo el horizonte (sólo se ve con archivo activo).
visible: bool,
color: Color,
label: String,
}
/// Mundo → pantalla local (relativa al rect del lienzo). El centro del
/// rect es el ancla del zoom; `pan` se suma en mundo, luego se escala.
pub(crate) fn world_to_local(wx: f32, wy: f32, w: f32, h: f32, pan: (f32, f32), zoom: f32) -> (f32, f32) {
(w * 0.5 + (wx + pan.0) * zoom, h * 0.5 + (wy + pan.1) * zoom)
}
/// Inversa de [`world_to_local`]: pantalla local → mundo. Para resolver
/// qué nota cae bajo un click.
pub(crate) fn local_to_world(lx: f32, ly: f32, w: f32, h: f32, pan: (f32, f32), zoom: f32) -> (f32, f32) {
let z = zoom.max(1e-3);
((lx - w * 0.5) / z - pan.0, (ly - h * 0.5) / z - pan.1)
}
/// La nota colocada más cercana a un click en coords locales, dentro de un
/// radio de tolerancia (~18 px de pantalla). `None` si el click cae en el
/// vacío — así arrastrar el fondo no cambia la selección.
pub(crate) fn pick_note(model: &Model, lx: f32, ly: f32, w: f32, h: f32) -> Option<NoteId> {
let (wx, wy) = local_to_world(lx, ly, w, h, model.cam_pan, model.cam_zoom);
let now = now_secs();
let mut best: Option<(NoteId, f32)> = None;
for id in &model.order {
let Some(n) = model.store.get(*id) else { continue };
let Some((px, py)) = n.pos else { continue };
if !model.show_archive {
let m = current_mass(&model.gravity, n, now);
if !model.gravity.is_visible(m) {
continue;
}
}
let d2 = (px - wx).powi(2) + (py - wy).powi(2);
if best.map(|(_, bd)| d2 < bd).unwrap_or(true) {
best = Some((*id, d2));
}
}
let tol = (18.0 / model.cam_zoom.max(1e-3)).powi(2);
best.filter(|(_, d2)| *d2 <= tol).map(|(id, _)| id)
}
/// Separación mínima entre nodos al colocarlos (coordenadas de mundo).
pub(crate) const MAP_MIN_SEP: f32 = 30.0;
/// Ángulo áureo en radianes — reparte determinísticamente lo que no tiene
/// parentela semántica sin amontonarlo.
pub(crate) const GOLDEN_ANGLE: f32 = 2.399_963_2;
/// Le da a `id` un domicilio fijo en el mapa, **una sola vez**: cae en el
/// baricentro de sus parientes semánticos (ponderado por afinidad) y, si
/// quedó pegada a otra nota, se separa apenas. Determinista y dependiente
/// sólo de las notas ya asentadas, así el orden de inserción es estable y
/// el mapa nunca se reacomoda solo.
pub(crate) fn place_note(model: &mut Model, id: NoteId) {
if model.store.get(id).map(|n| n.pos.is_some()).unwrap_or(true) {
return; // ya tiene domicilio (o no existe): no se mueve.
}
// Vecinos ya colocados: su afinidad con la nota nueva y su posición.
let mut kin: Vec<(f32, (f32, f32))> = Vec::new();
for other in &model.order {
if *other == id {
continue;
}
let Some(pos) = model.store.get(*other).and_then(|n| n.pos) else { continue };
let aff = model.field.affinity(id, *other).unwrap_or(0.0).max(0.0);
kin.push((aff, pos));
}
let target = if kin.is_empty() {
(0.0, 0.0) // primera nota del cuaderno: centro del mundo.
} else {
let wsum: f32 = kin.iter().map(|(w, _)| *w).sum();
if wsum > 1e-3 {
// Cae junto a su parentela: baricentro ponderado por afinidad.
let (mut tx, mut ty) = (0.0_f32, 0.0_f32);
for (w, (x, y)) in &kin {
tx += w * x;
ty += w * y;
}
(tx / wsum, ty / wsum)
} else {
// Ortogonal a todo: anillo determinista por id, lejos del núcleo.
let ang = id as f32 * GOLDEN_ANGLE;
let rad = 180.0 + 14.0 * (id as f32).sqrt();
(rad * ang.cos(), rad * ang.sin())
}
};
// Separación: empuja el target hasta despegarlo de cada vecino cercano.
let mut p = target;
for _ in 0..12 {
let mut moved = false;
for (_, q) in &kin {
let dx = p.0 - q.0;
let dy = p.1 - q.1;
let d = (dx * dx + dy * dy).sqrt();
if d < MAP_MIN_SEP {
let (ux, uy) = if d > 1e-3 {
(dx / d, dy / d)
} else {
let a = id as f32 * GOLDEN_ANGLE;
(a.cos(), a.sin())
};
let push = MAP_MIN_SEP - d;
p.0 += ux * push;
p.1 += uy * push;
moved = true;
}
}
if !moved {
break;
}
}
model.store.set_pos(id, p.0, p.1);
}
/// Envuelve `child` como cajón absoluto pegado al borde izquierdo, alto
/// completo con un margen. El mapa de fondo sigue recibiendo pan/zoom en
/// el resto de la ventana; sólo los clicks sobre el cajón los come él.
pub(crate) fn overlay_left(child: View<Msg>, width: f32) -> View<Msg> {
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(8.0_f32),
top: length(8.0_f32),
bottom: length(8.0_f32),
right: auto(),
},
size: Size {
width: length(width),
height: auto(),
},
flex_direction: FlexDirection::Column,
..Default::default()
})
.children(vec![child])
}
/// Columna interna del editor: barra de cierre (× ⇒ deselecciona) arriba +
/// el editor abajo, sobre `bg_panel`. La comparten el overlay lateral y la
/// tarjeta anclada del zoom semántico.
pub(crate) fn editor_shell(child: View<Msg>, theme: &Theme) -> View<Msg> {
let close = button("× cerrar", theme.bg_button, theme.fg_muted, Msg::Deselect);
let close_row = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(28.0_f32),
},
flex_shrink: 0.0,
justify_content: Some(JustifyContent::End),
align_items: Some(AlignItems::Center),
padding: Rect {
left: length(6.0_f32),
right: length(6.0_f32),
top: length(2.0_f32),
bottom: length(2.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel_alt)
.children(vec![close]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel)
.children(vec![close_row, child])
}
/// Envuelve `child` como panel absoluto pegado al borde derecho, alto
/// completo, con barra de cierre. El editor del nodo abierto cuando se lo
/// edita de lejos (zoom bajo): un fallback práctico al anclaje in-situ.
pub(crate) fn overlay_right(child: View<Msg>, width: f32, theme: &Theme) -> View<Msg> {
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: auto(),
top: length(8.0_f32),
bottom: length(8.0_f32),
right: length(8.0_f32),
},
size: Size {
width: length(width),
height: auto(),
},
flex_direction: FlexDirection::Column,
..Default::default()
})
.children(vec![editor_shell(child, theme)])
}
/// Mundo → pantalla local usando el último tamaño de lienzo conocido + la
/// cámara. La versión de `view()`, donde el rect real aún no se sabe.
pub(crate) fn world_screen(model: &Model, wx: f32, wy: f32) -> (f32, f32) {
let (w, h) = model.canvas_size;
world_to_local(wx, wy, w, h, model.cam_pan, model.cam_zoom)
}
/// Posición de pantalla (local al lienzo) del nodo `id`. `None` si la nota
/// no tiene domicilio todavía.
pub(crate) fn node_screen_pos(model: &Model, id: NoteId) -> Option<(f32, f32)> {
let (wx, wy) = model.store.get(id).and_then(|n| n.pos)?;
Some(world_screen(model, wx, wy))
}
/// Mínimo de notas para que un clúster cuente como región candidata.
pub(crate) const REGION_MIN_MEMBERS: usize = 3;
/// Distancia de mundo dentro de la cual una región ya "posee" un clúster:
/// si hay un topónimo así de cerca del centroide, no se vuelve a ofrecer.
pub(crate) const REGION_MATCH_DIST: f32 = 140.0;
/// Centroides (mundo) de los clústeres densos que todavía no tienen una
/// región cerca — los lugares que el mapa ofrece bautizar. Sólo cuentan
/// miembros colocados y visibles.
pub(crate) fn unnamed_cluster_centroids(model: &Model) -> Vec<(f32, f32)> {
let now = now_secs();
let mut out = Vec::new();
for cluster in model.field.clusters(CLUSTER_THRESHOLD) {
let pts: Vec<(f32, f32)> = cluster
.iter()
.filter_map(|id| {
let n = model.store.get(*id)?;
let p = n.pos?;
let m = current_mass(&model.gravity, n, now);
(model.show_archive || model.gravity.is_visible(m)).then_some(p)
})
.collect();
if pts.len() < REGION_MIN_MEMBERS {
continue;
}
let (sx, sy) = pts.iter().fold((0.0, 0.0), |(ax, ay), (x, y)| (ax + x, ay + y));
let c = (sx / pts.len() as f32, sy / pts.len() as f32);
let d2 = REGION_MATCH_DIST * REGION_MATCH_DIST;
let near_named = model
.regions
.iter()
.any(|r| (r.x - c.0).powi(2) + (r.y - c.1).powi(2) <= d2);
let naming_here = model
.naming
.map(|(nx, ny)| (nx - c.0).powi(2) + (ny - c.1).powi(2) <= d2)
.unwrap_or(false);
if !near_named && !naming_here {
out.push(c);
}
}
out
}
/// Chip clickeable "✛ nombrar zona" que ofrece bautizar el clúster denso
/// en `(wx, wy)`. Al click abre el input de bautizo en esa coordenada.
pub(crate) fn name_region_chip(wx: f32, wy: f32, theme: &Theme) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(theme.bg_button)
.radius(12.0)
.hover_fill(theme.bg_button_hover)
.text_aligned("✛ nombrar zona", 11.0, theme.fg_muted, Alignment::Center)
.on_click(Msg::BeginNaming(wx, wy))
}
/// Mini-input del bautizo en curso: una tarjeta con el campo de texto
/// enfocado. Enter confirma, Esc cancela (en `on_key`).
pub(crate) fn naming_input(model: &Model, input_palette: &TextInputPalette) -> View<Msg> {
let input = text_input_view(
&model.region_input,
"nombre de la zona…",
model.focus == Focus::Region,
input_palette,
Msg::Focus(Focus::Region),
);
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
align_items: Some(AlignItems::Center),
padding: Rect {
left: length(6.0_f32),
right: length(6.0_f32),
top: length(4.0_f32),
bottom: length(4.0_f32),
},
..Default::default()
})
.fill(model.theme.bg_panel)
.radius(8.0)
.children(vec![input])
}
/// Posiciona `child` como vista absoluta de tamaño `(w, h)` centrada en la
/// pantalla `(sx, sy)`, clampeada al lienzo. Para chips y mini-inputs que
/// viven en el mapa (sugerencia de bautizo, input de nombre).
pub(crate) fn pinned(child: View<Msg>, sx: f32, sy: f32, w: f32, h: f32, canvas: (f32, f32)) -> View<Msg> {
let left = (sx - w * 0.5).clamp(4.0, (canvas.0 - w - 4.0).max(4.0));
let top = (sy - h * 0.5).clamp(4.0, (canvas.1 - h - 4.0).max(4.0));
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(left),
top: length(top),
right: auto(),
bottom: auto(),
},
size: Size {
width: length(w),
height: length(h),
},
..Default::default()
})
.children(vec![child])
}
/// La tarjeta del nodo abierto, anclada a su coordenada `(nx, ny)` de
/// pantalla: el zoom semántico hecho carne — el editor vive EN el lugar del
/// pensamiento, no en un panel aparte. Se clampea para no salirse del
/// lienzo. Hija del canvas, así pan/zoom la arrastran con el nodo.
pub(crate) fn node_card(child: View<Msg>, nx: f32, ny: f32, canvas: (f32, f32), theme: &Theme) -> View<Msg> {
let (cw_max, ch_max) = canvas;
let cw = 380.0_f32.min((cw_max - 16.0).max(220.0));
let ch = 440.0_f32.min((ch_max - 16.0).max(200.0));
// Anclada bajo el nodo, centrada en X, clampeada a la ventana.
let left = (nx - cw * 0.5).clamp(8.0, (cw_max - cw - 8.0).max(8.0));
let top = (ny + 16.0).clamp(8.0, (ch_max - ch - 8.0).max(8.0));
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(left),
top: length(top),
right: auto(),
bottom: auto(),
},
size: Size {
width: length(cw),
height: length(ch),
},
flex_direction: FlexDirection::Column,
..Default::default()
})
.radius(8.0)
.children(vec![editor_shell(child, theme)])
}
pub(crate) fn gravity_panel(model: &Model, injected: Vec<View<Msg>>) -> View<Msg> {
let theme = model.theme;
let now = now_secs();
let clusters = model.field.clusters(CLUSTER_THRESHOLD);
let selected = model.selected;
let pan = model.cam_pan;
let zoom = model.cam_zoom;
// Nodos colocados (los que ya tienen domicilio), con su masa viva.
let mut nodes: Vec<MapNode> = Vec::new();
for id in &model.order {
let Some(n) = model.store.get(*id) else { continue };
let Some((x, y)) = n.pos else { continue };
let mass = current_mass(&model.gravity, n, now);
let visible = model.gravity.is_visible(mass);
if !visible && !model.show_archive {
continue;
}
nodes.push(MapNode {
id: *id,
x,
y,
mass,
visible,
color: cluster_color(*id, &clusters, theme),
label: short_label(&n.title),
});
}
// Topónimos: las regiones bautizadas, para pintarlas como rótulos de
// continente detrás de los nodos.
let regions: Vec<(String, f32, f32)> = model
.regions
.iter()
.map(|r| (r.name.clone(), r.x, r.y))
.collect();
// Filamentos del nodo seleccionado: sus parientes más afines ya
// colocados. Elegir un pensamiento enciende sus vecinos (activación
// por difusión) — el motor de serendipia.
let mut links: Vec<((f32, f32), (f32, f32), f32)> = Vec::new();
if let Some(sel) = selected {
if let Some(sp) = model.store.get(sel).and_then(|n| n.pos) {
for (nid, aff) in model.field.nearest(sel, 6) {
if aff < 0.20 {
continue;
}
if let Some(np) = model.store.get(nid).and_then(|n| n.pos) {
links.push((sp, np, aff));
}
}
}
}
let canvas = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel_alt)
.paint_with(move |scene, ts, rect| {
paint_map(scene, ts, rect, &nodes, &links, &regions, selected, pan, zoom, theme);
})
.draggable_at(|phase, dx, dy, _lx, _ly| match phase {
DragPhase::Move => Some(Msg::MapPan(dx, dy)),
DragPhase::End => None,
})
.on_scroll(|_dx, dy| Some(Msg::MapZoom(dy)))
.on_click_at(|lx, ly, w, h| Some(Msg::MapClick(lx, ly, w, h)))
// La tarjeta del nodo abierto (zoom semántico) viaja como hija del
// canvas: se pinta encima de los nodos y la cámara la arrastra con el
// pensamiento al que pertenece.
.children(injected);
View::new(Style {
size: Size {
width: Dimension::auto(),
height: percent(1.0_f32),
},
flex_grow: 1.0,
flex_basis: length(0.0_f32),
min_size: Size {
width: length(0.0_f32),
height: length(0.0_f32),
},
padding: Rect {
left: length(4.0_f32),
right: length(4.0_f32),
top: length(4.0_f32),
bottom: length(4.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel)
.children(vec![canvas])
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn paint_map(
scene: &mut llimphi_ui::llimphi_raster::vello::Scene,
ts: &mut llimphi_ui::llimphi_text::Typesetter,
rect: llimphi_ui::PaintRect,
nodes: &[MapNode],
links: &[((f32, f32), (f32, f32), f32)],
regions: &[(String, f32, f32)],
selected: Option<NoteId>,
pan: (f32, f32),
zoom: f32,
theme: Theme,
) {
if rect.w <= 0.0 || rect.h <= 0.0 {
return;
}
// Pantalla absoluta = origen del rect + pantalla local.
let to_screen = |wx: f32, wy: f32| -> (f64, f64) {
let (lx, ly) = world_to_local(wx, wy, rect.w, rect.h, pan, zoom);
((rect.x + lx) as f64, (rect.y + ly) as f64)
};
// Topónimos al fondo: el nombre de cada región, grande y tenue, como
// rótulo de continente; un halo suave insinúa su territorio.
for (name, rx, ry) in regions {
let (cx, cy) = to_screen(*rx, *ry);
let blob = KurboCircle::new((cx, cy), (96.0 * zoom as f64).max(34.0));
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
with_alpha(theme.accent, 0.05),
None,
&blob,
);
let size = (15.0 * zoom).clamp(11.0, 28.0);
// Centrado aproximado: `simple` alinea a la izquierda en (x, y).
let est_w = name.chars().count() as f64 * size as f64 * 0.52;
draw_block(
scene,
ts,
&TextBlock::simple(
name,
size,
with_alpha(theme.fg_text, 0.30),
(cx - est_w * 0.5, cy - size as f64 * 0.6),
),
);
}
// Filamentos primero (debajo de los nodos). Más opacos cuanto más afín.
for (a, b, aff) in links {
let (ax, ay) = to_screen(a.0, a.1);
let (bx, by) = to_screen(b.0, b.1);
let mut path = BezPath::new();
path.move_to((ax, ay));
path.line_to((bx, by));
let alpha = (0.18 + aff * 0.55).clamp(0.0, 0.85);
scene.stroke(
&Stroke::new((0.8 + *aff as f64 * 1.6).max(0.6)),
Affine::IDENTITY,
with_alpha(theme.accent, alpha),
None,
&path,
);
}
// Nodos: tamaño y brillo crecen con la masa viva (el mapa respira).
for n in nodes {
let (px, py) = to_screen(n.x, n.y);
let m = n.mass.clamp(0.0, 2.0);
// Radio base por masa, escalado apenas por zoom para no inflarse.
let r = (3.0 + m * 4.5) * (0.6 + 0.4 * zoom.clamp(0.5, 1.5));
// Brillo: las notas frescas arden; las que se enfrían se apagan
// hacia el fondo. Bajo el horizonte (archivo) van casi transparentes.
let glow = if n.visible {
(0.35 + m * 0.45).clamp(0.0, 1.0)
} else {
0.18
};
let color = with_alpha(n.color, glow);
// Halo tenue alrededor de las notas más encendidas.
if n.visible && m > 0.6 {
let halo = KurboCircle::new((px, py), (r + 5.0) as f64);
scene.fill(Fill::NonZero, Affine::IDENTITY, with_alpha(n.color, 0.10), None, &halo);
}
let circle = KurboCircle::new((px, py), r as f64);
scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &circle);
if selected == Some(n.id) {
let ring = KurboCircle::new((px, py), (r + 3.0) as f64);
scene.stroke(&Stroke::new(2.0), Affine::IDENTITY, theme.accent, None, &ring);
}
// Etiqueta: sólo si el zoom da espacio o es la seleccionada — para
// no saturar el mapa lejano. El texto sale del Typesetter.
if (zoom >= 0.9 || selected == Some(n.id)) && n.visible {
let lbl_col = with_alpha(theme.fg_text, (glow + 0.25).clamp(0.0, 1.0));
draw_block(
scene,
ts,
&TextBlock::simple(&n.label, 10.0, lbl_col, (px + r as f64 + 4.0, py - 7.0)),
);
}
}
}
pub(crate) fn cluster_color(id: NoteId, clusters: &[Vec<NoteId>], theme: Theme) -> Color {
let idx = clusters.iter().position(|c| c.contains(&id)).unwrap_or(0);
// Paleta tomada del theme + matices generados por golden-ratio
// sobre el hue del accent. Determinista por índice.
let palette: [Color; 6] = [
theme.accent,
with_alpha(rotate_hue(theme.accent, 0.16), 1.0),
with_alpha(rotate_hue(theme.accent, 0.33), 1.0),
with_alpha(rotate_hue(theme.accent, 0.50), 1.0),
with_alpha(rotate_hue(theme.accent, 0.66), 1.0),
with_alpha(rotate_hue(theme.accent, 0.83), 1.0),
];
palette[idx % palette.len()]
}
pub(crate) fn with_alpha(c: Color, alpha: f32) -> Color {
let [r, g, b, _] = c.components;
Color::new([r, g, b, alpha])
}
pub(crate) fn rotate_hue(c: Color, dh: f32) -> Color {
// RGB → HSV → rota H → RGB. Aproximación, alpha fijo.
let [r, g, b, a] = c.components;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let v = max;
let s = if max <= 0.0 { 0.0 } else { (max - min) / max };
let h = if (max - min).abs() < 1e-6 {
0.0
} else if max == r {
((g - b) / (max - min)) % 6.0
} else if max == g {
(b - r) / (max - min) + 2.0
} else {
(r - g) / (max - min) + 4.0
};
let h2 = ((h / 6.0) + dh).rem_euclid(1.0) * 6.0;
let c2 = v * s;
let x = c2 * (1.0 - ((h2 % 2.0) - 1.0).abs());
let (r2, g2, b2) = match h2 as i32 {
0 => (c2, x, 0.0),
1 => (x, c2, 0.0),
2 => (0.0, c2, x),
3 => (0.0, x, c2),
4 => (x, 0.0, c2),
_ => (c2, 0.0, x),
};
let m = v - c2;
Color::new([r2 + m, g2 + m, b2 + m, a])
}
pub(crate) fn short_label(s: &str) -> String {
let mut out: String = s.chars().take(24).collect();
if s.chars().count() > 24 {
out.push('…');
}
out
}
+149
View File
@@ -0,0 +1,149 @@
//! `menu` — menú principal de khipu y menú de edición contextual.
//!
//! Construye el `AppMenu` reflejando el estado del campo focuseado e
//! informa los `EditFlags` del campo activo. El despacho de comandos a
//! `Msg` reales vive en `app.rs` para evitar un ciclo de módulos.
use llimphi_theme::Theme;
use llimphi_widget_edit_menu::EditFlags;
use llimphi_widget_menubar::{MenuBarSpec, DEFAULT_HEIGHT as MENU_H};
use llimphi_widget_text_editor::EditorState;
use crate::modelo::{Focus, Model, Msg};
// =====================================================================
// Helpers de foco
// =====================================================================
/// Devuelve el `EditorState` del campo focuseado (referencia inmutable) y
/// si está enmascarado (passphrase). Sin foco editable devuelve `None`.
pub(crate) fn focused_editor(model: &Model) -> (Option<&EditorState>, bool) {
match model.focus {
Focus::Body => (Some(&model.body), false),
Focus::Captura => (Some(model.captura.editor()), false),
Focus::Title => (Some(model.title.editor()), false),
Focus::Tags => (Some(model.tags.editor()), false),
Focus::Search => (Some(model.search.editor()), false),
Focus::PeerAddr => (Some(model.peer_input.editor()), false),
Focus::Nombre => (Some(model.nombre_input.editor()), false),
Focus::Passphrase => (Some(model.passphrase.editor()), model.passphrase.is_masked()),
Focus::None => (None, false),
}
}
/// `EditFlags` del campo focuseado, para nav/ejecución por teclado del
/// menú de edición. Sin campo focuseado, flags vacíos (todo gris).
pub(crate) fn focused_edit_flags(model: &Model) -> EditFlags {
let (editor, masked) = focused_editor(model);
match editor {
Some(ed) => EditFlags::from_editor(ed, masked),
None => EditFlags::default(),
}
}
// =====================================================================
// Menú principal
// =====================================================================
/// Construye el menú principal de khipu reflejando el estado del campo
/// focuseado (ítems de Editar grises sin selección / historial) y de la
/// cuerda seleccionada (verbos de Cuerda grises sin una).
pub(crate) fn app_menu(model: &Model) -> app_bus::AppMenu {
use app_bus::{AppMenu, Menu, MenuItem};
let (editor, _masked) = focused_editor(model);
let has_sel = editor.map(|e| e.has_selection()).unwrap_or(false);
let can_undo = editor.map(|e| e.can_undo()).unwrap_or(false);
let can_redo = editor.map(|e| e.can_redo()).unwrap_or(false);
let has_field = editor.is_some();
let has_sel_note = model.selected.is_some();
let has_sel_cuerda = model.cuerda_sel.is_some();
let mut undo = MenuItem::new("Deshacer", "edit.undo").shortcut("Ctrl+Z");
if !can_undo {
undo = undo.disabled();
}
let mut redo = MenuItem::new("Rehacer", "edit.redo").shortcut("Ctrl+Y");
if !can_redo {
redo = redo.disabled();
}
let mut cut = MenuItem::new("Cortar", "edit.cut").shortcut("Ctrl+X").separated();
let mut copy = MenuItem::new("Copiar", "edit.copy").shortcut("Ctrl+C");
if !has_sel {
cut = cut.disabled();
copy = copy.disabled();
}
let mut paste = MenuItem::new("Pegar", "edit.paste").shortcut("Ctrl+V");
if !has_field {
paste = paste.disabled();
}
let mut sel_all = MenuItem::new("Seleccionar todo", "edit.selectall")
.shortcut("Ctrl+A")
.separated();
if !has_field {
sel_all = sel_all.disabled();
}
let mut delete_note = MenuItem::new("Borrar nudo", "note.delete");
if !has_sel_note {
delete_note = delete_note.disabled();
}
let mut tejer = MenuItem::new("Tejer (encuadre LLM)", "cuerda.tejer");
let mut renombrar = MenuItem::new("Renombrar cuerda…", "cuerda.renombrar");
let mut desatar = MenuItem::new("Desatar cuerda", "cuerda.desatar").separated();
if !has_sel_cuerda || model.tejiendo.is_some() {
tejer = tejer.disabled();
}
if !has_sel_cuerda {
renombrar = renombrar.disabled();
desatar = desatar.disabled();
}
AppMenu::new()
.menu(
Menu::new("Archivo")
.item(MenuItem::new("Capturar (foco)", "note.captura").shortcut("Ctrl+N"))
.item(delete_note)
.item(MenuItem::new("Exportar sobre…", "share.export").separated())
.item(MenuItem::new("Importar sobre…", "share.import")),
)
.menu(
Menu::new("Editar")
.item(undo)
.item(redo)
.item(cut)
.item(copy)
.item(paste)
.item(sel_all),
)
.menu(Menu::new("Cuerda").item(tejer).item(renombrar).item(desatar))
.menu(
Menu::new("Compartir")
.item(MenuItem::new("Publicar (P2P)", "share.publish"))
.item(MenuItem::new("Recibir de un par…", "share.receive")),
)
.menu(
Menu::new("Ayuda")
.item(MenuItem::new("Buscar (foco)", "view.search").shortcut("Ctrl+F"))
.item(MenuItem::new("Acerca de khipu", "help.about")),
)
}
/// Arma el `MenuBarSpec` compartido por `menubar_view` y `menubar_overlay`.
/// Recibe el tamaño del viewport para no crear una dependencia de `KhipuApp`.
pub(crate) fn menubar_spec<'a>(
menu: &'a app_bus::AppMenu,
model: &Model,
theme: &'a Theme,
viewport: (f32, f32),
) -> MenuBarSpec<'a, Msg> {
MenuBarSpec {
menu,
open: model.menu_open,
theme,
viewport,
height: MENU_H,
on_open: std::sync::Arc::new(Msg::MenuOpen),
on_command: std::sync::Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
}
}
+441
View File
@@ -0,0 +1,441 @@
//! `modelo` — tipos centrales de khipu-app: constantes, enums de foco y
//! mensajes, estructuras de modelo y estado P2P.
//!
//! Todo lo que `update`, `view` y los submódulos necesitan conocer para
//! tipar sus firmas; cero lógica de negocio aquí.
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use agora_core::Keypair;
use khipu_core::{CuerdaId, NoteId, NoteStore};
use khipu_gravity::SemanticField;
use khipu_share::SignedBundle;
use llimphi_clipboard::SystemClipboard;
use llimphi_motion::Tween;
use llimphi_theme::Theme;
use llimphi_widget_text_editor::EditorState;
use llimphi_widget_text_input::{TextInputEvent, TextInputState};
use rimay_verbo::Provider;
// =====================================================================
// Constantes globales
// =====================================================================
/// Dimensión del embebedor local (fallback sin daemon).
pub(crate) const EMBED_DIM: usize = 16;
pub(crate) const EDITOR_VISIBLE_LINES: usize = 24;
pub(crate) const LIST_WIDTH: f32 = 260.0;
/// Ancho del editor flotante (overlay derecho sobre el lienzo de cuerdas).
pub(crate) const EDITOR_OVERLAY_W: f32 = 420.0;
pub(crate) const HEADER_H: f32 = 36.0;
pub(crate) const ROW_H: f32 = 26.0;
pub(crate) const FIELD_LABEL_SIZE: f32 = 10.0;
/// Cuántos vecinos como máximo ofrece el panel de resonancia.
pub(crate) const RESONANCIA_MAX: usize = 4;
/// Umbral de afinidad para que un vecino cuente como resonancia, según
/// el espacio vectorial: los embeddings reales (e5) viven con baseline
/// alto; el trigram local es mucho más disperso.
pub(crate) const UMBRAL_RESONANCIA_REMOTO: f32 = 0.78;
pub(crate) const UMBRAL_RESONANCIA_LOCAL: f32 = 0.22;
/// Un nudo suelto sin tocar durante este lapso sedimenta: sigue ahí,
/// buscable, pero deja la franja de sueltos frescos. 14 días.
pub(crate) const SEDIMENTO_SECS: u64 = 14 * 24 * 3600;
/// Máximo de chips en la franja de sueltos frescos.
pub(crate) const SUELTOS_MAX: usize = 10;
// =====================================================================
// Embedder
// =====================================================================
/// Fuente de vectores semánticos. Con un `verbo-daemon` en el socket por
/// defecto usa embeddings reales; si no hay daemon cae al hash-trigram
/// local de 16d — determinista, offline, sin runtime.
///
/// El arm remoto guarda el `Runtime` de tokio para resolver las llamadas
/// async del `Provider` con `block_on` desde el hilo worker que las
/// dispara (nunca el de UI). Es `Clone` (todo tras `Arc`) para viajar
/// barato dentro de la closure de `Handle::spawn`.
#[derive(Clone)]
pub(crate) enum Embedder {
/// Daemon `rimay-verbo` por socket Unix.
Remote {
provider: Arc<dyn Provider>,
rt: Arc<tokio::runtime::Runtime>,
dim: usize,
label: String,
},
/// Fallback local: hash-trigram → R^EMBED_DIM, sin red ni runtime.
Local,
}
impl Embedder {
/// Conecta al `verbo-daemon` en el socket por defecto. Si no hay
/// ninguno (o no se pudo armar el runtime), devuelve el embebedor
/// local — los demos arrancan igual, sin red.
pub(crate) fn connect() -> Self {
let rt = match tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
{
Ok(rt) => rt,
Err(_) => return Embedder::Local,
};
match rt.block_on(rimay_verbo::conectar()) {
Ok(client) => {
let id = client.model_id();
let dim = id.dimension;
let label = id.to_string();
Embedder::Remote {
provider: Arc::new(client),
rt: Arc::new(rt),
dim,
label,
}
}
Err(_) => Embedder::Local,
}
}
/// Etiqueta del espacio vectorial. Si cambia entre dos arranques, los
/// vectores persistidos son incomparables y hay que recalcularlos.
pub(crate) fn label(&self) -> String {
match self {
Embedder::Remote { label, .. } => label.clone(),
Embedder::Local => format!("khipu-trigram-{EMBED_DIM}d"),
}
}
/// Umbral de afinidad para la resonancia en este espacio vectorial.
pub(crate) fn umbral_resonancia(&self) -> f32 {
match self {
Embedder::Remote { .. } => UMBRAL_RESONANCIA_REMOTO,
Embedder::Local => UMBRAL_RESONANCIA_LOCAL,
}
}
/// Embebe `text` de forma bloqueante. En el arm remoto resuelve el
/// future con `block_on`; ante un error devuelve un vector de ceros.
pub(crate) fn embed_blocking(&self, text: &str) -> Vec<f32> {
match self {
Embedder::Local => khipu_gravity::local_embed(text, EMBED_DIM),
Embedder::Remote { provider, rt, dim, .. } => rt
.block_on(provider.embed(text))
.map(|v| v.values)
.unwrap_or_else(|_| vec![0.0; *dim]),
}
}
}
// =====================================================================
// Focus
// =====================================================================
/// Foco activo del teclado. Cualquier `KeyEvent` se rutea al input
/// correspondiente; sin foco las teclas se ignoran.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Focus {
None,
/// La barra de captura — el gesto raíz de khipu.
Captura,
Search,
Title,
Body,
Tags,
Passphrase,
PeerAddr,
/// Input del nombre de una cuerda (bautizo o renombre).
Nombre,
}
// =====================================================================
// Resonancia
// =====================================================================
/// El eco de una captura: el nudo recién botado y los nudos previos que
/// tocó (afinidad sobre el umbral del espacio vectorial). El panel de
/// resonancia lo muestra apenas llega el embedding — ese instante es el
/// cierre de la fuga: anudar en vez de duplicar.
#[derive(Clone)]
pub(crate) struct Resonancia {
/// El nudo recién capturado.
pub(crate) nudo: NoteId,
/// Vecinos que resuenan: `(id, afinidad)`, de mayor a menor.
/// Vacío mientras el embedding viaja al worker.
pub(crate) vecinos: Vec<(NoteId, f32)>,
/// `true` hasta que el embedding vuelve del worker.
pub(crate) calculando: bool,
}
// =====================================================================
// P2p
// =====================================================================
/// Nodo libp2p del cuaderno + su runtime tokio. Se arma perezosamente la
/// primera vez que se usa P2P (`ensure_p2p`).
pub(crate) struct P2p {
pub(crate) rt: Arc<tokio::runtime::Runtime>,
pub(crate) node: Arc<khipu_brahman::KhipuNode>,
/// Nuestra dirección para compartir (`/ip4/.../tcp/.../p2p/<id>`).
pub(crate) dial_addr: String,
/// `true` cuando ya estamos sirviendo el cuaderno por libp2p.
pub(crate) serving: bool,
}
// =====================================================================
// PeerInfo
// =====================================================================
/// Un par descubierto en la red, en forma lista para la UI.
#[derive(Clone)]
pub(crate) struct PeerInfo {
/// Dirección TCP de fetch, como string (`ip:puerto`).
pub(crate) addr: String,
/// Etiqueta para la fila: nombre · de:autor · dirección.
pub(crate) label: String,
}
// =====================================================================
// Msg
// =====================================================================
#[derive(Clone)]
pub(crate) enum Msg {
// --- captura y resonancia ---
/// Enter en la barra de captura: bota el nudo y dispara la resonancia.
Capturar,
/// Anuda el nudo a una cuerda existente (acción del panel de resonancia
/// o del editor).
Anudar(NoteId, CuerdaId),
/// Anuda dos nudos sueltos en una cuerda nueva (nombre propuesto del
/// contenido).
AnudarJuntos(NoteId, NoteId),
/// Crea una cuerda nueva con este único nudo.
NuevaCuerda(NoteId),
/// Cierra el panel de resonancia dejando el nudo suelto.
DescartarResonancia,
/// Reabre la resonancia de un nudo suelto (desde el editor): vuelve a
/// preguntar con quién resuena, con su embedding vigente.
Resonar(NoteId),
/// Suelta un nudo de su cuerda.
Desanudar(NoteId),
/// Cicla el estado accionable del nudo: Normal → Pendiente → Hecho.
CiclarEstado(NoteId),
// --- cuerdas ---
/// Selecciona (o deselecciona con `None`) una cuerda en el lienzo.
SelectCuerda(Option<CuerdaId>),
/// Abre el input de renombre de una cuerda, precargado.
RenombrarCuerda(CuerdaId),
/// Confirma el renombre con el texto tipeado.
CommitNombre,
/// Cancela el renombre.
CancelNombre,
/// Borra una cuerda: sus nudos vuelven a estar sueltos.
BorrarCuerda(CuerdaId),
/// Teje la cuerda: pide al LLM el encuadre de sus nudos.
Tejer(CuerdaId),
/// Resultado async del tejido (`Ok(texto)` o `Err(mensaje)`).
TejidoListo(CuerdaId, Result<String, String>),
/// Pan horizontal del lienzo de cuerdas (delta de arrastre en px).
CuerdasPan(f32),
/// Click en el lienzo en coords locales `(lx, ly)` sobre `(w, h)`.
CuerdasClick(f32, f32, f32, f32),
// --- notas / edición ---
SelectNote(NoteId),
DeleteSelected,
Focus(Focus),
/// Evento de mouse de un campo de texto (click/arrastre). El `Focus`
/// identifica cuál; un `Press` además lo enfoca (y confirma la edición
/// previa). Ver [`llimphi_widget_text_input::text_input_view_full`].
Campo(Focus, TextInputEvent),
Key(llimphi_ui::KeyEvent),
EditorPointer(llimphi_widget_text_editor::PointerEvent),
/// Latido lento — refresca la frescura pintada de las cuerdas.
Tick,
/// Resultado async de un embed: `(nota, secuencia, vector)`.
EmbeddingReady(NoteId, u64, Vec<f32>),
/// Cierra el editor flotante: deselecciona y vuelve al lienzo limpio.
Deselect,
/// Abre/cierra el cajón de nudos (overlay izquierdo).
ToggleList,
/// Escape en el lienzo: cierra lo de más arriba.
EscapeMapa,
// --- compartir ---
/// Sella todo el cuaderno en un sobre firmado (`compartido.khipu`).
Export,
/// Verifica e ingiere `compartido.khipu` como notas nuevas.
Import,
/// Empieza a servir el cuaderno por TCP para que un par lo jale.
Publish,
/// Busca pares en la LAN para jalarles el cuaderno.
Receive,
/// Resultado del descubrimiento: los pares vistos (ya sin uno mismo).
PeersFound(Vec<PeerInfo>),
/// Jala el cuaderno del par en esta dirección TCP.
FetchFrom(String),
/// Jala de la dirección escrita a mano (input) — habilita WAN.
FetchManual,
/// Cierra el panel de recibir sin jalar nada.
CancelPeers,
/// Resultado async de un fetch: el sobre recibido o un error.
Received(Result<SignedBundle, String>),
/// Intenta desbloquear la identidad con la passphrase tipeada.
Unlock,
/// Cierra el prompt de passphrase sin desbloquear.
CancelUnlock,
/// Resultado async de reservar un circuito en un relay.
RelayReady(String),
// --- menús ---
/// Abre/cierra un dropdown de la barra de menú principal.
MenuOpen(Option<usize>),
/// Comando elegido en la barra de menú.
MenuCommand(String),
/// Navegación por teclado en el menú principal.
MenuNav(i32),
/// Enter en el menú principal: ejecuta la fila activa.
MenuActivate,
/// Tick de animación de menús (sólo re-render).
MenuTick,
/// Navegación por teclado en el menú de edición.
EditNav(i32),
/// Enter en el menú de edición: ejecuta la fila activa.
EditActivate,
/// Abre el menú de edición contextual (right-click) en coords de ventana.
EditMenuOpen(f32, f32),
/// Acción de edición sobre el campo focuseado.
EditMenuAction(llimphi_widget_edit_menu::EditAction),
/// Cierra cualquier menú abierto (principal o de edición).
CloseMenus,
}
// =====================================================================
// Model
// =====================================================================
pub(crate) struct Model {
pub(crate) store: NoteStore,
pub(crate) field: SemanticField,
/// Orden de inserción (estable).
pub(crate) order: Vec<NoteId>,
pub(crate) selected: Option<NoteId>,
/// La barra de captura — siempre lista arriba.
pub(crate) captura: TextInputState,
/// El eco de la última captura, si el panel sigue abierto.
pub(crate) resonancia: Option<Resonancia>,
/// Cuerda seleccionada en el lienzo (muestra su tarjeta).
pub(crate) cuerda_sel: Option<CuerdaId>,
/// Cuerda en renombre, si el input está abierto.
pub(crate) renombrando: Option<CuerdaId>,
/// Input del nombre de cuerda (bautizo/renombre).
pub(crate) nombre_input: TextInputState,
/// Cuerda cuyo tejido está viajando al LLM, si hay uno en curso.
pub(crate) tejiendo: Option<CuerdaId>,
/// Pan horizontal del lienzo de cuerdas (px, ≥ 0).
pub(crate) pan_x: f32,
pub(crate) title: TextInputState,
pub(crate) body: EditorState,
pub(crate) tags: TextInputState,
pub(crate) search: TextInputState,
pub(crate) focus: Focus,
pub(crate) theme: Theme,
pub(crate) data_path: Option<PathBuf>,
/// Fuente de embeddings: daemon `verbo` o fallback trigram local.
pub(crate) embedder: Embedder,
/// Última secuencia de embedding pedida por nota.
pub(crate) embed_latest: BTreeMap<NoteId, u64>,
/// Contador monótono de pedidos de embedding.
pub(crate) embed_seq: u64,
/// Identidad Ed25519 del cuaderno.
pub(crate) keypair: Option<Keypair>,
/// Última línea de estado (export/import/red/tejido).
pub(crate) status: Option<String>,
/// `true` cuando ya hay un servidor TCP sirviendo el cuaderno.
pub(crate) publishing: bool,
/// `true` mientras el panel izquierdo está en modo "recibir".
pub(crate) receiving: bool,
/// Pares descubiertos en la última búsqueda.
pub(crate) peers: Vec<PeerInfo>,
/// Dirección manual del par para recibir (habilita WAN).
pub(crate) peer_input: TextInputState,
/// Input de la passphrase para desbloquear la identidad.
pub(crate) passphrase: TextInputState,
/// `true` mientras se muestra el prompt de passphrase (modal).
pub(crate) unlocking: bool,
/// Acción a reanudar tras desbloquear.
pub(crate) pending: Option<Box<Msg>>,
/// Nodo libp2p (perezoso).
pub(crate) p2p: Option<P2p>,
/// Dropdown abierto de la barra de menú (índice), o `None`.
pub(crate) menu_open: Option<usize>,
/// Fila resaltada por teclado en el menú principal.
pub(crate) menu_active: usize,
/// Animación de aparición/swap del dropdown del menú principal.
pub(crate) menu_anim: Tween<f32>,
/// Posición del menú de edición contextual, si abierto.
pub(crate) edit_menu: Option<(f32, f32)>,
/// Fila resaltada por teclado en el menú de edición.
pub(crate) edit_active: usize,
/// Animación de aparición del menú de edición.
pub(crate) edit_anim: Tween<f32>,
/// Portapapeles del sistema.
pub(crate) clipboard: SystemClipboard,
/// `true` mientras el cajón de nudos (overlay izquierdo) está abierto.
pub(crate) show_list: bool,
/// Último tamaño conocido del lienzo `(w, h)` en pixels.
pub(crate) canvas_size: (f32, f32),
}
impl Model {
/// Constructor vacío base; lo usan `from_state` y `seeded_model`
/// para no repetir la inicialización de todos los campos.
pub(crate) fn blank(embedder: Embedder) -> Self {
Model {
store: NoteStore::new(),
field: SemanticField::new(),
order: Vec::new(),
selected: None,
captura: TextInputState::new(),
resonancia: None,
cuerda_sel: None,
renombrando: None,
nombre_input: TextInputState::new(),
tejiendo: None,
pan_x: 0.0,
title: TextInputState::new(),
body: EditorState::default(),
tags: TextInputState::new(),
search: TextInputState::new(),
focus: Focus::Captura,
theme: Theme::dark(),
data_path: None,
embedder,
embed_latest: BTreeMap::new(),
embed_seq: 0,
keypair: None,
status: None,
publishing: false,
receiving: false,
peers: Vec::new(),
peer_input: TextInputState::new(),
passphrase: TextInputState::masked(),
unlocking: false,
pending: None,
p2p: None,
menu_open: None,
menu_active: usize::MAX,
menu_anim: Tween::idle(1.0),
edit_menu: None,
edit_active: usize::MAX,
edit_anim: Tween::idle(1.0),
clipboard: SystemClipboard::new(),
show_list: false,
canvas_size: (1280.0, 640.0),
}
}
}
+3 -2
View File
@@ -1,7 +1,7 @@
//! `net` — el lado P2P del cuaderno: sellar/exportar el sobre firmado,
//! verificar e ingerir uno ajeno, descubrir pares y publicarse por libp2p.
//! El transporte (LAN directo + WAN vía `card-net`/Kademlia + relay/NAT)
//! vive en `khipu-share`/`khipu-brahman`; a sólo se orquesta.
//! vive en `khipu-share`/`khipu-brahman`; aquí sólo se orquesta.
use std::sync::Arc;
@@ -9,7 +9,8 @@ use khipu_share::{SharedNote, SignedBundle};
use llimphi_ui::Handle;
use crate::panels::note_matches;
use crate::{khipu_dir, now_secs, schedule_embedding, Model, Msg, P2p};
use crate::estado::{khipu_dir, now_secs, schedule_embedding};
use crate::modelo::{Model, Msg, P2p};
pub(crate) fn export_notebook(model: &Model) -> String {
let Some(kp) = model.keypair.as_ref() else {
File diff suppressed because it is too large Load Diff
+139
View File
@@ -0,0 +1,139 @@
//! `tejido` — la síntesis de una cuerda: el encuadre que khipu redacta
//! sobre los nudos de una idea recurrente.
//!
//! El tejido existe para el momento en que una idea engordó pero sigue
//! difusa (el caso canónico: la idea de khipu mismo). Junta los nudos en
//! cronología y le pide al LLM un encuadre en tres partes: qué se viene
//! repitiendo, qué ya está claro, y qué falta decidir (las fugas). El
//! resultado es un nudo-síntesis **editable** anudado al final de la
//! cuerda; re-tejer lo reemplaza.
//!
//! El backend sale de `pluma-llm::from_env()`: Anthropic/Gemini/DeepSeek/
//! Cohere/Ollama según el entorno, y **Mock sin credenciales** — el botón
//! siempre hace algo, aunque sea un eco de prueba. La llamada corre en un
//! worker (`Handle::spawn`) con su propio runtime: nunca bloquea la UI.
use khipu_core::{Cuerda, Note};
use llimphi_ui::Handle;
use pluma_llm::pluma_llm_core::ChatRequest;
use crate::modelo::{Model, Msg};
/// Tokens máximos de la respuesta — un encuadre, no un ensayo.
const MAX_TOKENS: u32 = 1024;
const SYSTEM: &str = "Eres el tejedor de un khipu: recibes los nudos (pensamientos \
sueltos capturados en distintos momentos y estados mentales) de una cuerda — una \
idea recurrente de la misma persona. Tejé un encuadre breve en español, directo y \
sin florituras, con exactamente estas tres secciones:\n\
\n\
## Lo que se repite\nla idea central que insiste a través de los nudos.\n\
## Lo que ya está claro\ndecisiones o certezas que los nudos ya asentaron.\n\
## Lo que falta\nlas fugas: qué queda por decidir o hacer para concretarla.\n\
\n\
Máximo ~250 palabras. No inventes contenido que los nudos no digan; si un nudo \
contradice a otro, señalalo en «Lo que falta».";
/// Arma el prompt de usuario: los nudos de la cuerda en cronología, con
/// su fecha, excluyendo el tejido anterior (la síntesis no se cita a sí
/// misma — se reemplaza).
pub(crate) fn prompt_de(cuerda: &Cuerda, nudos: &[&Note]) -> String {
let mut out = format!("Cuerda: «{}»\n\nNudos, en orden de captura:\n", cuerda.nombre);
for n in nudos {
if cuerda.tejido == Some(n.id) {
continue;
}
out.push_str(&format!("- [{}] {}", fecha_corta(n.created_at), n.title));
if !n.body.trim().is_empty() {
out.push_str(&format!("\n {}", n.body.trim().replace('\n', "\n ")));
}
if n.estado == khipu_core::EstadoNudo::Pendiente {
out.push_str(" (pendiente)");
}
out.push('\n');
}
out
}
/// Segundo Unix → `aaaa-mm-dd` sin dependencias de calendario (días
/// civiles desde epoch, algoritmo de Howard Hinnant).
fn fecha_corta(secs: u64) -> String {
let dias = (secs / 86_400) as i64;
let z = dias + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02}")
}
/// Dispara el tejido de `cuerda` en un worker. Reentra al `update` con
/// [`Msg::TejidoListo`] — texto del encuadre o error legible.
pub(crate) fn tejer(model: &Model, cuerda: khipu_core::CuerdaId, h: &Handle<Msg>) {
let Some(c) = model.store.cuerda(cuerda) else {
return;
};
let nudos = model.store.nudos_de(cuerda);
let prompt = prompt_de(c, &nudos);
h.spawn(move || {
let resultado = (|| -> Result<String, String> {
let client = pluma_llm::from_env().map_err(|e| format!("sin backend LLM: {e}"))?;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| format!("sin runtime: {e}"))?;
let req = ChatRequest::una_vuelta(prompt, MAX_TOKENS).con_sistema(SYSTEM);
let resp = rt
.block_on(client.complete(&req))
.map_err(|e| format!("falló el tejido: {e}"))?;
if resp.content.trim().is_empty() {
return Err("el modelo devolvió un tejido vacío".into());
}
Ok(resp.content)
})();
Msg::TejidoListo(cuerda, resultado)
});
}
#[cfg(test)]
mod tests {
use super::*;
use khipu_core::NoteStore;
#[test]
fn prompt_lists_nudos_chronologically_without_tejido() {
let mut s = NoteStore::new();
let c = s.crear_cuerda("khipu", 0);
let a = s.create("primera idea", "con cuerpo", vec![], 100);
let b = s.create("segunda idea", "", vec![], 200_000_000);
let t = s.create("tejido · khipu", "síntesis vieja", vec![], 300_000_000);
for id in [a, b, t] {
s.anudar(id, c);
}
s.cuerda_mut(c).unwrap().tejido = Some(t);
let cuerda = s.cuerda(c).unwrap();
let nudos = s.nudos_de(c);
let p = prompt_de(cuerda, &nudos);
assert!(p.contains("«khipu»"));
assert!(p.contains("primera idea"));
assert!(p.contains("con cuerpo"));
assert!(p.contains("segunda idea"));
// El tejido anterior no se cita a sí mismo.
assert!(!p.contains("síntesis vieja"));
// Cronología: la primera antes que la segunda.
assert!(p.find("primera").unwrap() < p.find("segunda").unwrap());
}
#[test]
fn fecha_corta_maps_known_dates() {
assert_eq!(fecha_corta(0), "1970-01-01");
assert_eq!(fecha_corta(86_400), "1970-01-02");
// 2026-07-14 00:00 UTC (verificado con `date -u -d @1783987200`).
assert_eq!(fecha_corta(1_783_987_200), "2026-07-14");
}
}
@@ -12,7 +12,6 @@ description = "khipu-brahman — transporte de sobres khipu sobre libp2p (Brahma
khipu-share = { path = "../khipu-share" }
card-net = { workspace = true }
libp2p = { workspace = true }
libp2p-stream = { workspace = true }
tokio = { workspace = true }
tokio-util = { workspace = true }
futures = { workspace = true }
+28
View File
@@ -0,0 +1,28 @@
# khipu-brahman
*Read this in English: [README.md](README.md).*
Transporte de sobres khipu sobre libp2p, vía la capa P2P compartida [`card_net::BrahmanNet`] (TCP + Noise + Yamux + Kademlia).
Es el hermano WAN del transporte LAN de `khipu-share::net`: en vez de un
`TcpStream` directo, abre un stream libp2p sobre el protocolo
`SOBRE_PROTOCOL` y manda el mismo sobre serializado. Como el sobre va
firmado y direccionado por contenido, **el transporte no necesita ser
confiable**: quien recibe verifica con `khipu_share::open` antes de
ingerir. El cifrado Noise sólo agrega confidencialidad en tránsito.
El descubrimiento es por DHT Kademlia: `KhipuNode::anunciar` publica
bajo una clave fija y `KhipuNode::descubrir` lista a quién la provee —
rendezvous sin saber la `Multiaddr` de antemano (hace falta al menos un
peer bootstrap en la tabla, vía `KhipuNode::add_peer`). La travesía de
NAT (relay + dcutr + autonat) **sí** está cableada en `BrahmanNet`
(`shared/card/card-net`); ver el test `jalar_a_traves_de_un_relay` en
`tests/p2p_roundtrip.rs`, que jala un sobre a través de un circuito relay.
Detrás de NAT simétrico todavía conviene un relay público alcanzable.
Marco de cable: `u32` big-endian con el largo del sobre + el sobre
(postcard). Un sobre por stream — espejo del marco de `khipu-share::net`.
---
Parte de **khipu** — ver [khipu](../LEEME.md).
+12
View File
@@ -0,0 +1,12 @@
# khipu-brahman
Transport of khipu envelopes over libp2p, through the shared P2P layer
`card_net::BrahmanNet` (TCP + Noise + Yamux + Kademlia).
It is the WAN sibling of `khipu-share::net`'s LAN transport: instead of a direct
`TcpStream`, it opens a libp2p stream over the `SOBRE_PROTOCOL` protocol and sends
the same serialized envelope.
---
Part of **khipu** — see [khipu](../README.md).
+17 -9
View File
@@ -1,21 +1,29 @@
# khipu-core
> Modelo de nota + store para [khipu](../README.md). Sin UI.
*Read this in English: [README.md](README.md).*
`Note` lleva contenido, timestamp de creación, timestamp de último acceso, y `mass: f32`. El store es CRUD simple sobre archivos JSON en `$XDG_DATA_HOME/khipu/`. Cada vez que se lee una nota se actualiza su `last_access` (señal que [khipu-gravity](../khipu-gravity/README.md) usa para reforzar).
> El núcleo agnóstico de [khipu](../LEEME.md): nudos, cuerdas y el grafo de enlaces. Sin UI, sin disco, sin red.
- **`Note` (el nudo)**: título, cuerpo, etiquetas, timestamps; `cuerda: Option<CuerdaId>` (a lo sumo una), `estado` (Normal/Pendiente/Hecho — TODOs sin due-dates) y `origen` (estampa de la app que lo capturó, fase 2). Conserva `mass`/`last_access` para los consumidores con física temporal (pata, iniy-derive).
- **`Cuerda`**: una idea recurrente — nombre, fecha, y su `tejido: Option<NoteId>` (el nudo-síntesis). No guarda la lista de nudos: se deriva (`nudos_de`) para que no haya dos fuentes de verdad.
- **`NoteStore`**: CRUD determinista (`BTreeMap`), cuerdas (`crear_cuerda`/`anudar`/`desanudar`/`sueltos`/`borrar_cuerda`), grafo de wiki-links `[[Título]]` (forward/back/orphans/dangling), `insert_raw` + `coherente()` para migraciones seguras.
- **`nombre::propose_cuerda_name`**: el bautizo — el término más compartido y saliente de títulos/etiquetas del grupo.
## API
```rust
use khipu_core::{Note, Store};
use khipu_core::NoteStore;
let store = Store::open()?;
let id = store.create("nota nueva")?;
let note = store.read(id)?;
store.touch(id)?; // refresca last_access
let mut s = NoteStore::new();
let a = s.create("idea que vuelve", "", vec![], now);
let b = s.create("la misma idea, otra vez", "", vec![], now);
let c = s.crear_cuerda("idea", now);
s.anudar(a, c);
s.anudar(b, c);
assert_eq!(s.nudos_de(c).len(), 2);
assert!(s.sueltos().is_empty());
```
## Deps
- `serde`, `serde_json`
- `directories` para el XDG path
- `serde` (los tipos viajan por postcard/JSON según el caller)
+15 -9
View File
@@ -1,21 +1,27 @@
# khipu-core
> Note model + store for [khipu](../README.md). No UI.
> The agnostic core of [khipu](../README.md): knots, cords and the link graph. No UI, no disk, no network.
`Note` carries content, creation timestamp, last-access timestamp, and `mass: f32`. The store is simple CRUD over JSON files in `$XDG_DATA_HOME/khipu/`. Every read updates `last_access` (the signal [khipu-gravity](../khipu-gravity/README.md) uses to reinforce).
- **`Note` (the knot)**: title, body, tags, timestamps; `cuerda: Option<CuerdaId>` (at most one), `estado` (Normal/Pendiente/Hecho — TODOs without due-dates) and `origen` (stamp of the capturing app, phase 2). Keeps `mass`/`last_access` for consumers with temporal physics (pata, iniy-derive).
- **`Cuerda`**: a recurrent idea — name, date, and its `tejido: Option<NoteId>` (the synthesis knot). It doesn't store its knot list: it's derived (`nudos_de`) so there are never two sources of truth.
- **`NoteStore`**: deterministic CRUD (`BTreeMap`), cords (`crear_cuerda`/`anudar`/`desanudar`/`sueltos`/`borrar_cuerda`), wiki-link graph `[[Title]]` (forward/back/orphans/dangling), `insert_raw` + `coherente()` for safe migrations.
- **`nombre::propose_cuerda_name`**: the christening — the most shared, salient term across the group's titles/tags.
## API
```rust
use khipu_core::{Note, Store};
use khipu_core::NoteStore;
let store = Store::open()?;
let id = store.create("new note")?;
let note = store.read(id)?;
store.touch(id)?; // refresh last_access
let mut s = NoteStore::new();
let a = s.create("recurring idea", "", vec![], now);
let b = s.create("same idea, again", "", vec![], now);
let c = s.crear_cuerda("idea", now);
s.anudar(a, c);
s.anudar(b, c);
assert_eq!(s.nudos_de(c).len(), 2);
assert!(s.sueltos().is_empty());
```
## Deps
- `serde`, `serde_json`
- `directories` for XDG path
- `serde` (types travel as postcard/JSON depending on the caller)
+16 -10
View File
@@ -1,23 +1,29 @@
//! `khipu_app-core` — el núcleo agnóstico de la toma de notas.
//! `khipu-core` — el núcleo agnóstico del cuaderno de resonancia.
//!
//! Una nota es texto con título, etiquetas y enlaces `[[...]]`. El
//! [`NoteStore`] las guarda y deriva el grafo: forward-links, backlinks,
//! huérfanas y enlaces colgantes. Sin UI, sin storage en disco, sin red
//! — tipos puros y deterministas.
//! La unidad es el **nudo** ([`Note`]): un pensamiento botado en
//! cualquier estado mental — una idea, un TODO, una cita. Los nudos que
//! resuenan entre sí se anudan en una **cuerda** ([`Cuerda`]): una idea
//! recurrente que crece con cada retorno, en vez de olvidarse. El
//! [`NoteStore`] guarda ambos y deriva el grafo: forward-links,
//! backlinks, nudos por cuerda, sueltos. Sin UI, sin storage en disco,
//! sin red — tipos puros y deterministas.
//!
//! - [`note`] — el modelo [`Note`].
//! - [`note`] — el modelo [`Note`] (nudo), [`Cuerda`], [`EstadoNudo`], [`Origen`].
//! - [`links`] — el parser de wiki-links `[[...]]`.
//! - [`store`] — el [`NoteStore`] y el grafo de enlaces.
//! - [`store`] — el [`NoteStore`]: nudos + cuerdas + grafo de enlaces.
//! - [`nombre`] — propuesta de nombre para una cuerda naciente.
//!
//! La gravedad semántica (clustering por afinidad de embeddings) vive en
//! `khipu_app-gravity`; las lentes visuales, en los crates de frontend.
//! La resonancia semántica (afinidad de embeddings) vive en
//! `khipu-gravity`; las lentes visuales, en los crates de frontend.
#![forbid(unsafe_code)]
pub mod links;
pub mod nombre;
pub mod note;
pub mod store;
pub use links::parse_links;
pub use note::{Note, NoteId};
pub use nombre::propose_cuerda_name;
pub use note::{Cuerda, CuerdaId, EstadoNudo, Note, NoteId, Origen};
pub use store::NoteStore;
+143
View File
@@ -0,0 +1,143 @@
//! Propuesta de nombre para una cuerda naciente.
//!
//! Cuando dos o más nudos resuenan y se anudan, la cuerda necesita un
//! nombre. No se le pide al usuario que lo invente en frío: se propone el
//! término más recurrente y saliente de los títulos y etiquetas de sus
//! nudos, y el bautizo arranca con esa sugerencia ya cargada — se acepta
//! o se edita. Puro y determinista.
use crate::note::Note;
/// Propone un nombre para un grupo de nudos: el término más recurrente y
/// saliente de sus títulos y etiquetas. Prefiere lo **compartido** (lo
/// que aparece en más nudos es lo que los vuelve una idea), luego lo más
/// frecuente, y desempata alfabético para ser determinista. `None` si no
/// hay ninguna palabra con señal (todo vacío o palabras vacías).
pub fn propose_cuerda_name(notes: &[&Note]) -> Option<String> {
use std::collections::{BTreeMap, BTreeSet};
// Por término: (en cuántos nudos aparece, frecuencia total).
let mut stats: BTreeMap<String, (u32, u32)> = BTreeMap::new();
for n in notes {
let mut seen: BTreeSet<String> = BTreeSet::new();
for tok in note_tokens(n) {
let e = stats.entry(tok.clone()).or_insert((0, 0));
e.1 += 1;
if seen.insert(tok) {
e.0 += 1;
}
}
}
if stats.is_empty() {
return None;
}
// max_by: gana doc-freq, luego freq total; en empate, el alfabético
// menor (lo invertimos con `kb.cmp(ka)` para que el menor sea "mayor").
let best = stats
.iter()
.max_by(|(ka, va), (kb, vb)| {
va.0.cmp(&vb.0).then(va.1.cmp(&vb.1)).then(kb.cmp(ka))
})
.map(|(k, _)| k.clone())?;
Some(capitalize(&best))
}
/// Tokens significativos de un nudo: palabras del título + etiquetas,
/// en minúsculas, sin palabras vacías ni fragmentos cortos.
fn note_tokens(n: &Note) -> Vec<String> {
let mut out = Vec::new();
let push = |raw: &str, out: &mut Vec<String>| {
let t = raw.to_lowercase();
if t.chars().count() >= 3 && t.chars().any(|c| c.is_alphabetic()) && !is_stopword(&t) {
out.push(t);
}
};
for raw in n.title.split(|c: char| !c.is_alphanumeric()) {
push(raw, &mut out);
}
for tag in &n.tags {
for raw in tag.split(|c: char| !c.is_alphanumeric()) {
push(raw, &mut out);
}
}
out
}
/// Mayúscula inicial respetando unicode (para mostrar el nombre).
fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
/// Palabras vacías (español, con algunas de inglés) que no sirven de
/// nombre aunque sean frecuentes.
fn is_stopword(t: &str) -> bool {
const STOP: &[&str] = &[
"los", "las", "una", "unos", "unas", "del", "por", "para", "con", "sin", "que",
"como", "más", "mas", "pero", "sus", "este", "esta", "esto", "estos", "estas",
"ese", "esa", "eso", "esos", "esas", "the", "and", "for", "with", "you", "este",
"muy", "son", "fue", "han", "hay", "sobre", "entre", "cuando", "donde", "porque",
];
STOP.contains(&t)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::note::{EstadoNudo, NoteId};
fn note(id: NoteId, title: &str, tags: &[&str]) -> Note {
Note {
id,
title: title.into(),
body: String::new(),
tags: tags.iter().map(|s| s.to_string()).collect(),
created_at: 0,
updated_at: 0,
last_access: 0,
mass: 1.0,
cuerda: None,
estado: EstadoNudo::Normal,
origen: None,
}
}
#[test]
fn propose_picks_the_shared_term() {
let a = note(1, "Receta de pan", &["cocina"]);
let b = note(2, "Horno y pan", &["cocina"]);
let c = note(3, "Masa madre", &["cocina"]);
// "cocina" está en los tres nudos → gana por doc-freq.
let name = propose_cuerda_name(&[&a, &b, &c]).unwrap();
assert_eq!(name, "Cocina");
}
#[test]
fn propose_prefers_shared_over_merely_frequent() {
// "pan" aparece 2 veces pero en un solo nudo; "viaje" en dos.
let a = note(1, "Pan pan pan", &[]);
let b = note(2, "Viaje a la costa", &[]);
let c = note(3, "Viaje largo", &[]);
let name = propose_cuerda_name(&[&a, &b, &c]).unwrap();
assert_eq!(name, "Viaje");
}
#[test]
fn propose_ignores_stopwords_and_short_tokens() {
let a = note(1, "El de la y", &[]);
let b = note(2, "Por que con", &[]);
// Todo son palabras vacías o de <3 letras → sin señal.
assert!(propose_cuerda_name(&[&a, &b]).is_none());
}
#[test]
fn propose_is_deterministic_on_ties() {
// "alpha" y "omega" empatan en doc-freq y freq → gana el alfabético.
let a = note(1, "alpha omega", &[]);
let b = note(2, "omega alpha", &[]);
assert_eq!(propose_cuerda_name(&[&a, &b]).unwrap(), "Alpha");
}
}
+84 -19
View File
@@ -1,18 +1,50 @@
//! El modelo `Note` — la unidad de khipu_app.
//! El modelo `Note` — el **nudo**, la unidad de captura de khipu.
//!
//! Un nudo es un pensamiento botado en cualquier estado mental: una idea,
//! un TODO, una cita. Nace suelto; si resuena con otros, se anuda a una
//! [`crate::Cuerda`]. No hay carpetas ni jerarquías previas — la
//! estructura emerge de la recurrencia.
use serde::{Deserialize, Serialize};
use crate::links::parse_links;
/// Identificador de una nota. Lo asigna el almacén, monótono y estable.
/// Identificador de una nota (nudo). Lo asigna el almacén, monótono y estable.
pub type NoteId = u64;
/// Una nota: título, cuerpo, etiquetas y marcas de tiempo. Los enlaces
/// no se guardan aparte — se derivan del cuerpo bajo demanda.
/// Identificador de una cuerda. Lo asigna el almacén, monótono y estable.
pub type CuerdaId = u64;
/// Estado accionable de un nudo. Un TODO es una expresión mental más —
/// no hay due-dates ni recordatorios, sólo el gesto de marcarlo.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum EstadoNudo {
/// Un pensamiento sin más — la mayoría.
#[default]
Normal,
/// Accionable y sin hacer.
Pendiente,
/// Accionable y hecho. Se conserva: es memoria, no basura.
Hecho,
}
/// De dónde vino un nudo capturado fuera de la app: qué app lo mandó y
/// una referencia legible (id de correo, nombre de pacha, workspace…).
/// La estampa es del momento de captura; khipu no la reinterpreta.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Origen {
/// App de origen (`chasqui`, `pata`, `pacha`, …).
pub app: String,
/// Referencia dentro de esa app, legible y opaca para khipu.
pub referencia: String,
}
/// Una nota — el nudo: título, cuerpo, etiquetas y marcas de tiempo.
/// Los enlaces no se guardan aparte — se derivan del cuerpo bajo demanda.
///
/// `mass` y `last_access` son la señal temporal: la masa decae con el
/// tiempo y `last_access` registra cuándo fue la última lectura. La
/// física la aplica el caller (típicamente con `khipu-gravity`).
/// `mass` y `last_access` son la física temporal *opcional* (la usan
/// consumidores como la captura rápida de pata vía `khipu-gravity`); la
/// app khipu ya no olvida — organiza por resonancia y sedimentación.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Note {
pub id: NoteId,
@@ -28,20 +60,21 @@ pub struct Note {
/// caller decide cómo arrancar la masa.
#[serde(default)]
pub last_access: u64,
/// Masa de la nota. 1.0 al crearse; decae con el tiempo, sube con
/// cada acceso. Cuando cae bajo el umbral del horizonte la nota
/// pasa al archivo (no se borra). En payloads viejos arranca en 1.0.
/// Masa de la nota, para los consumidores con física temporal
/// (`khipu-gravity::Gravity`). La app khipu no la decae.
#[serde(default = "default_mass")]
pub mass: f32,
/// Posición ancla en el lienzo del mapa, en coordenadas de mundo.
/// Se fija **una sola vez** cuando la nota se coloca (por gravedad
/// semántica, contra las notas ya asentadas) y se persiste: el mapa
/// es estable — un pensamiento conserva su domicilio para que la
/// memoria espacial pueda recordarlo. `None` = todavía sin colocar
/// (el caller le asigna lugar la primera vez que la pinta). En
/// payloads viejos arranca en `None` y se coloca al cargar.
/// Cuerda a la que este nudo está anudado. `None` = suelto (recién
/// capturado o nunca resonó — con el tiempo, sedimento).
#[serde(default)]
pub pos: Option<(f32, f32)>,
pub cuerda: Option<CuerdaId>,
/// Estado accionable (TODO). `Normal` para la mayoría.
#[serde(default)]
pub estado: EstadoNudo,
/// Estampa de origen si el nudo llegó desde otra app. `None` =
/// capturado en khipu mismo.
#[serde(default)]
pub origen: Option<Origen>,
}
fn default_mass() -> f32 {
@@ -65,6 +98,28 @@ impl Note {
let q = query.to_lowercase();
self.title.to_lowercase().contains(&q) || self.body.to_lowercase().contains(&q)
}
/// `true` si el nudo está suelto (sin cuerda).
pub fn suelto(&self) -> bool {
self.cuerda.is_none()
}
}
/// Una cuerda: una idea recurrente. No se crea por adelantado — nace
/// cuando dos o más nudos resuenan y el usuario los anuda. Su masa no se
/// guarda: se deriva de sus nudos (`khipu_gravity::masa_cuerda`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Cuerda {
pub id: CuerdaId,
/// Nombre de la idea. Editable — se bautiza al nacer con una
/// propuesta derivada del contenido.
pub nombre: String,
/// Segundo Unix en que se anudó por primera vez.
pub created_at: u64,
/// El nudo-síntesis (tejido) de esta cuerda, si ya se tejió. Vive
/// como una nota más, anudada a la cuerda; re-tejer lo reemplaza.
#[serde(default)]
pub tejido: Option<NoteId>,
}
#[cfg(test)]
@@ -81,7 +136,9 @@ mod tests {
updated_at: 0,
last_access: 0,
mass: 1.0,
pos: None,
cuerda: None,
estado: EstadoNudo::Normal,
origen: None,
}
}
@@ -105,4 +162,12 @@ mod tests {
assert!(n.matches("pan"));
assert!(!n.matches("ausente"));
}
#[test]
fn new_notes_are_loose_by_default() {
let n = note("x", "y");
assert!(n.suelto());
assert_eq!(n.estado, EstadoNudo::Normal);
assert!(n.origen.is_none());
}
}
+324 -51
View File
@@ -1,31 +1,49 @@
//! `NoteStore` — el almacén de notas y su grafo de enlaces.
//! `NoteStore` — el almacén de nudos, cuerdas y su grafo de enlaces.
//!
//! Guarda las notas en un `BTreeMap` para que toda iteración sea
//! Guarda notas y cuerdas en `BTreeMap` para que toda iteración sea
//! determinista (ordenada por id). Los enlaces se resuelven por título
//! sin distinguir mayúsculas: `[[cocina]]` y `[[Cocina]]` apuntan a la
//! misma nota.
//! misma nota. Las cuerdas son la estructura emergente: un nudo pertenece
//! a lo sumo a una cuerda (`Note::cuerda`), y la cuerda no guarda la
//! lista — se deriva ([`NoteStore::nudos_de`]) para que no haya dos
//! fuentes de verdad que desincronizar.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::note::{Note, NoteId};
use crate::note::{Cuerda, CuerdaId, Note, NoteId};
/// El almacén de notas de khipu_app.
/// El almacén de nudos y cuerdas de khipu.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NoteStore {
notes: BTreeMap<NoteId, Note>,
/// Siguiente id a asignar — monótono, nunca reutiliza huecos.
next_id: NoteId,
/// Las cuerdas: ideas recurrentes bautizadas. Nuevas en el formato
/// V4; payloads viejos arrancan sin ninguna.
#[serde(default)]
cuerdas: BTreeMap<CuerdaId, Cuerda>,
#[serde(default = "one")]
next_cuerda: CuerdaId,
}
fn one() -> u64 {
1
}
impl NoteStore {
pub fn new() -> Self {
Self { notes: BTreeMap::new(), next_id: 1 }
Self {
notes: BTreeMap::new(),
next_id: 1,
cuerdas: BTreeMap::new(),
next_cuerda: 1,
}
}
/// Crea una nota y devuelve su id. Empieza con `mass = 1.0` y
/// `last_access = now` — recién nacida, plenamente visible.
/// Crea un nudo suelto y devuelve su id. Empieza con `mass = 1.0` y
/// `last_access = now` — recién nacido, sin cuerda.
pub fn create(
&mut self,
title: impl Into<String>,
@@ -46,12 +64,41 @@ impl NoteStore {
updated_at: now,
last_access: now,
mass: 1.0,
pos: None,
cuerda: None,
estado: Default::default(),
origen: None,
},
);
id
}
/// Inserta una nota **preservando su id** (reemplaza si ya existe) y
/// ajusta `next_id` para nunca reusarlo. Para migraciones de formato
/// e importaciones que deben conservar identidades — el camino normal
/// de creación es [`NoteStore::create`].
pub fn insert_raw(&mut self, note: Note) {
self.next_id = self.next_id.max(note.id + 1);
self.notes.insert(note.id, note);
}
/// Chequeo de coherencia interna: cada nota vive bajo su propio id,
/// los contadores no van a reusar ids vivos, y toda cuerda referida
/// por una nota (o por un tejido) existe. Lo usa la carga desde disco
/// para descartar decodificaciones corruptas de formatos viejos.
pub fn coherente(&self) -> bool {
let notas_ok = self.notes.iter().all(|(k, n)| {
*k == n.id
&& n.id < self.next_id
&& n.cuerda.map(|c| self.cuerdas.contains_key(&c)).unwrap_or(true)
});
let cuerdas_ok = self.cuerdas.iter().all(|(k, c)| {
*k == c.id
&& c.id < self.next_cuerda
&& c.tejido.map(|t| self.notes.contains_key(&t)).unwrap_or(true)
});
notas_ok && cuerdas_ok
}
pub fn len(&self) -> usize {
self.notes.len()
}
@@ -88,8 +135,8 @@ impl NoteStore {
}
}
/// Marca `last_access = now`. La señal que `khipu-gravity` usa para
/// reforzar la masa. No-op si la nota no existe; `true` si tocó.
/// Marca `last_access = now` — la señal de acceso para los
/// consumidores con física temporal. No-op si la nota no existe.
pub fn touch(&mut self, id: NoteId, now: u64) -> bool {
match self.notes.get_mut(&id) {
Some(n) => {
@@ -112,24 +159,136 @@ impl NoteStore {
}
}
/// Fija la posición ancla de una nota en el lienzo. Se llama una sola
/// vez al colocarla; de ahí en más el domicilio del pensamiento no se
/// mueve salvo que el usuario lo arrastre. `false` si la nota no existe.
pub fn set_pos(&mut self, id: NoteId, x: f32, y: f32) -> bool {
match self.notes.get_mut(&id) {
Some(n) => {
n.pos = Some((x, y));
true
/// Elimina una nota y la devuelve. Si era el tejido de su cuerda, la
/// cuerda queda sin tejido (se puede volver a tejer).
pub fn remove(&mut self, id: NoteId) -> Option<Note> {
let n = self.notes.remove(&id)?;
if let Some(cid) = n.cuerda {
if let Some(c) = self.cuerdas.get_mut(&cid) {
if c.tejido == Some(id) {
c.tejido = None;
}
}
None => false,
}
Some(n)
}
/// Elimina una nota y la devuelve.
pub fn remove(&mut self, id: NoteId) -> Option<Note> {
self.notes.remove(&id)
// =================================================================
// Cuerdas
// =================================================================
/// Crea una cuerda vacía con `nombre` y devuelve su id. Los nudos se
/// suman con [`NoteStore::anudar`].
pub fn crear_cuerda(&mut self, nombre: impl Into<String>, now: u64) -> CuerdaId {
let id = self.next_cuerda;
self.next_cuerda += 1;
self.cuerdas.insert(
id,
Cuerda {
id,
nombre: nombre.into(),
created_at: now,
tejido: None,
},
);
id
}
pub fn cuerda(&self, id: CuerdaId) -> Option<&Cuerda> {
self.cuerdas.get(&id)
}
pub fn cuerda_mut(&mut self, id: CuerdaId) -> Option<&mut Cuerda> {
self.cuerdas.get_mut(&id)
}
/// Itera las cuerdas en orden de id (determinista).
pub fn cuerdas(&self) -> impl Iterator<Item = &Cuerda> {
self.cuerdas.values()
}
pub fn cuerdas_len(&self) -> usize {
self.cuerdas.len()
}
/// Anuda `nota` a `cuerda`. Si estaba en otra cuerda, se muda (y si
/// era su tejido, esa cuerda queda sin tejido). `false` si la nota o
/// la cuerda no existen.
pub fn anudar(&mut self, nota: NoteId, cuerda: CuerdaId) -> bool {
if !self.cuerdas.contains_key(&cuerda) {
return false;
}
let Some(previa) = self.notes.get(&nota).map(|n| n.cuerda) else {
return false;
};
if let Some(prev) = previa.filter(|p| *p != cuerda) {
if let Some(c) = self.cuerdas.get_mut(&prev) {
if c.tejido == Some(nota) {
c.tejido = None;
}
}
}
if let Some(n) = self.notes.get_mut(&nota) {
n.cuerda = Some(cuerda);
}
true
}
/// Suelta un nudo de su cuerda (queda suelto). Si era el tejido, la
/// cuerda queda sin tejido. `false` si la nota no existe o ya estaba
/// suelta.
pub fn desanudar(&mut self, nota: NoteId) -> bool {
let Some(cid) = self.notes.get(&nota).and_then(|n| n.cuerda) else {
return false;
};
if let Some(c) = self.cuerdas.get_mut(&cid) {
if c.tejido == Some(nota) {
c.tejido = None;
}
}
if let Some(n) = self.notes.get_mut(&nota) {
n.cuerda = None;
}
true
}
/// Borra una cuerda. Sus nudos NO se borran — vuelven a estar
/// sueltos (la cuerda es estructura, no contenido). Devuelve la
/// cuerda si existía.
pub fn borrar_cuerda(&mut self, id: CuerdaId) -> Option<Cuerda> {
let c = self.cuerdas.remove(&id)?;
for n in self.notes.values_mut() {
if n.cuerda == Some(id) {
n.cuerda = None;
}
}
Some(c)
}
/// Los nudos de una cuerda, en orden cronológico de creación
/// (desempate por id — determinista). Incluye el tejido si lo hay.
pub fn nudos_de(&self, cuerda: CuerdaId) -> Vec<&Note> {
let mut out: Vec<&Note> = self
.notes
.values()
.filter(|n| n.cuerda == Some(cuerda))
.collect();
out.sort_by(|a, b| a.created_at.cmp(&b.created_at).then(a.id.cmp(&b.id)));
out
}
/// Los nudos sueltos (sin cuerda), del más reciente al más viejo
/// (desempate por id descendente — determinista).
pub fn sueltos(&self) -> Vec<&Note> {
let mut out: Vec<&Note> = self.notes.values().filter(|n| n.suelto()).collect();
out.sort_by(|a, b| b.created_at.cmp(&a.created_at).then(b.id.cmp(&a.id)));
out
}
// =================================================================
// Grafo de enlaces
// =================================================================
/// Notas que llevan la etiqueta `tag`, en orden de id.
pub fn by_tag(&self, tag: &str) -> Vec<&Note> {
self.notes.values().filter(|n| n.has_tag(tag)).collect()
@@ -278,8 +437,6 @@ mod tests {
#[test]
fn orphans_have_no_backlinks() {
let (s, _, _, jardin) = seeded();
// Jardín no recibe enlaces de vuelta... pero el Índice sí lo enlaza.
// El único huérfano real sería una nota aislada.
let mut s2 = s;
let aislada = s2.create("Aislada", "sin conexiones", vec![], 0);
let orphan_ids: Vec<_> = s2.orphans().iter().map(|n| n.id).collect();
@@ -301,6 +458,7 @@ mod tests {
let n = s.get(id).unwrap();
assert_eq!(n.mass, 1.0);
assert_eq!(n.last_access, 1_700_000_000);
assert!(n.suelto());
}
#[test]
@@ -312,32 +470,6 @@ mod tests {
assert!(!s.touch(9_999, 500));
}
#[test]
fn update_body_also_marks_last_access() {
let mut s = NoteStore::new();
let id = s.create("x", "y", vec![], 100);
assert!(s.update_body(id, "z", 700));
assert_eq!(s.get(id).unwrap().last_access, 700);
}
#[test]
fn set_mass_persists_the_value() {
let mut s = NoteStore::new();
let id = s.create("x", "y", vec![], 100);
assert!(s.set_mass(id, 0.42));
assert!((s.get(id).unwrap().mass - 0.42).abs() < 1e-6);
}
#[test]
fn set_pos_anchors_the_note_once() {
let mut s = NoteStore::new();
let id = s.create("x", "y", vec![], 100);
assert_eq!(s.get(id).unwrap().pos, None);
assert!(s.set_pos(id, 12.0, -8.0));
assert_eq!(s.get(id).unwrap().pos, Some((12.0, -8.0)));
assert!(!s.set_pos(9_999, 0.0, 0.0));
}
#[test]
fn remove_drops_the_note() {
let (mut s, indice, ..) = seeded();
@@ -345,4 +477,145 @@ mod tests {
assert_eq!(s.len(), 2);
assert!(s.get(indice).is_none());
}
// =================================================================
// Cuerdas
// =================================================================
#[test]
fn crear_cuerda_and_anudar() {
let (mut s, indice, cocina, jardin) = seeded();
let c = s.crear_cuerda("Casa", 200);
assert_eq!(s.cuerdas_len(), 1);
assert!(s.anudar(cocina, c));
assert!(s.anudar(jardin, c));
assert_eq!(s.get(cocina).unwrap().cuerda, Some(c));
let nudos: Vec<_> = s.nudos_de(c).iter().map(|n| n.id).collect();
assert_eq!(nudos, vec![cocina, jardin]);
// El índice sigue suelto.
assert!(s.get(indice).unwrap().suelto());
}
#[test]
fn anudar_rejects_missing_note_or_cuerda() {
let (mut s, indice, ..) = seeded();
let c = s.crear_cuerda("Casa", 200);
assert!(!s.anudar(9_999, c));
assert!(!s.anudar(indice, 9_999));
}
#[test]
fn anudar_moves_between_cuerdas() {
let (mut s, _, cocina, _) = seeded();
let a = s.crear_cuerda("A", 0);
let b = s.crear_cuerda("B", 0);
assert!(s.anudar(cocina, a));
assert!(s.anudar(cocina, b));
assert_eq!(s.get(cocina).unwrap().cuerda, Some(b));
assert!(s.nudos_de(a).is_empty());
}
#[test]
fn desanudar_frees_the_note() {
let (mut s, _, cocina, _) = seeded();
let c = s.crear_cuerda("Casa", 0);
s.anudar(cocina, c);
assert!(s.desanudar(cocina));
assert!(s.get(cocina).unwrap().suelto());
assert!(!s.desanudar(cocina)); // ya estaba suelto
}
#[test]
fn nudos_de_sorts_chronologically() {
let mut s = NoteStore::new();
let c = s.crear_cuerda("Idea", 0);
let tarde = s.create("tarde", "", vec![], 900);
let temprano = s.create("temprano", "", vec![], 100);
s.anudar(tarde, c);
s.anudar(temprano, c);
let ids: Vec<_> = s.nudos_de(c).iter().map(|n| n.id).collect();
assert_eq!(ids, vec![temprano, tarde]);
}
#[test]
fn sueltos_lists_newest_first() {
let mut s = NoteStore::new();
let viejo = s.create("viejo", "", vec![], 100);
let nuevo = s.create("nuevo", "", vec![], 900);
let ids: Vec<_> = s.sueltos().iter().map(|n| n.id).collect();
assert_eq!(ids, vec![nuevo, viejo]);
}
#[test]
fn borrar_cuerda_frees_its_nudos() {
let (mut s, _, cocina, jardin) = seeded();
let c = s.crear_cuerda("Casa", 0);
s.anudar(cocina, c);
s.anudar(jardin, c);
assert!(s.borrar_cuerda(c).is_some());
assert!(s.get(cocina).unwrap().suelto());
assert!(s.get(jardin).unwrap().suelto());
assert_eq!(s.cuerdas_len(), 0);
// El contenido sobrevive a la estructura.
assert_eq!(s.len(), 3);
}
#[test]
fn removing_tejido_clears_the_cuerda_pointer() {
let mut s = NoteStore::new();
let c = s.crear_cuerda("Idea", 0);
let sintesis = s.create("tejido · Idea", "encuadre", vec![], 100);
s.anudar(sintesis, c);
s.cuerda_mut(c).unwrap().tejido = Some(sintesis);
s.remove(sintesis);
assert_eq!(s.cuerda(c).unwrap().tejido, None);
}
#[test]
fn insert_raw_preserves_ids_and_bumps_counter() {
let mut s = NoteStore::new();
let mut n = Note {
id: 7,
title: "migrada".into(),
body: String::new(),
tags: vec![],
created_at: 1,
updated_at: 1,
last_access: 1,
mass: 1.0,
cuerda: None,
estado: Default::default(),
origen: None,
};
s.insert_raw(n.clone());
assert_eq!(s.get(7).unwrap().title, "migrada");
// El próximo create no pisa el id preservado.
let nuevo = s.create("x", "", vec![], 2);
assert_eq!(nuevo, 8);
// Reemplazo por id.
n.title = "editada".into();
s.insert_raw(n);
assert_eq!(s.get(7).unwrap().title, "editada");
assert!(s.coherente());
}
#[test]
fn coherente_detects_dangling_cuerda() {
let mut s = NoteStore::new();
let id = s.create("x", "", vec![], 0);
assert!(s.coherente());
s.get_mut(id).unwrap().cuerda = Some(99); // cuerda inexistente
assert!(!s.coherente());
}
#[test]
fn desanudar_tejido_clears_the_pointer() {
let mut s = NoteStore::new();
let c = s.crear_cuerda("Idea", 0);
let sintesis = s.create("tejido", "", vec![], 100);
s.anudar(sintesis, c);
s.cuerda_mut(c).unwrap().tejido = Some(sintesis);
assert!(s.desanudar(sintesis));
assert_eq!(s.cuerda(c).unwrap().tejido, None);
}
}
+7 -2
View File
@@ -9,7 +9,12 @@ description = "khipu — dos físicas puras: masa temporal (decay/refuerzo con v
[dependencies]
khipu-core = { path = "../khipu-core" }
# El almacenamiento e índice kNN de vectores lo provee el índice unificado de la
# suite (`rimay-verbo-index`): antes había tres copias del mismo coseno lineal.
# `SemanticField` guarda los vectores aquí dentro y construye grafo/gravedad/
# clústeres/layout 2D **encima**.
rimay-verbo-index = { workspace = true }
rimay-verbo-core = { workspace = true }
serde = { workspace = true }
# `libm`: la masa decae con `exp` y queremos mantenernos compatibles
# con futuros consumidores `no_std` (kernel wawa, WASM bare).
# `libm`: la masa decae con `exp` (física temporal — desacoplada del índice).
libm = { workspace = true }
+11 -8
View File
@@ -1,20 +1,23 @@
# khipu-gravity
> Algoritmo de masa/decay para [khipu](../README.md).
*Read this in English: [README.md](README.md).*
Pura física de notas. Cada nota tiene `mass: f32`. Cada tick (configurable, default 1h) la masa decae: `mass *= exp(-dt / half_life)`. Cada acceso la refuerza: `mass += boost`. Cuando `mass < umbral`, la nota cae del horizonte visible (no se borra, queda en archivo). El cálculo es puro — no toca el store; el caller decide qué hacer con el resultado.
> Las físicas puras de [khipu](../LEEME.md) — tres, deterministas, sin I/O ni reloj.
1. **Masa temporal** (`Gravity` + `Params`): decay exponencial por vida media + refuerzo por acceso + horizonte. La app khipu **ya no la usa** (el rediseño 2026-07 invirtió la física); la consumen la captura rápida de pata e `iniy-derive`.
2. **Campo semántico** (`SemanticField`): vectores por nota sobre `rimay-verbo-index`, afinidad coseno, `nearest` (la base de la **resonancia** de khipu), clústeres y layout 2D. `local_embed` es el fallback trigram offline.
3. **Masa de cuerda** (`masa_cuerda` + `frescura`): una cuerda engorda logarítmicamente con cada nudo y arde según cuán reciente fue su último crecimiento. La inversa del olvido: el refuerzo viene de la recurrencia, no de la lectura.
## API
```rust
use khipu_gravity::{Gravity, Params};
use khipu_gravity::{masa_cuerda, frescura, FRESCURA_MEDIA_VIDA_SECS};
let g = Gravity::new(Params::default());
let new_mass = g.decay(mass, dt);
let new_mass = g.reinforce(mass, boost);
let grosor = masa_cuerda(n_nudos); // 0 si vacía; retornos decrecientes
let brillo = frescura(ultimo_nudo, now, FRESCURA_MEDIA_VIDA_SECS); // 1.0 recién crecida → 0.5 al mes
```
## Deps
- `libm` para `exp` (sin `std::math` cuando se compila a WASM)
- Cero deps de I/O o tiempo (el caller pasa `dt`)
- `libm` para `exp`/`log` (apto `no_std` futuro)
- `khipu-core` (ids), `rimay-verbo-core`/`-index` (vectores)
+9 -8
View File
@@ -1,20 +1,21 @@
# khipu-gravity
> Mass/decay algorithm for [khipu](../README.md).
> The pure physics of [khipu](../README.md) — three of them, deterministic, no I/O, no clock.
Pure physics of notes. Each note has `mass: f32`. Every tick (configurable, default 1h) mass decays: `mass *= exp(-dt / half_life)`. Each access reinforces it: `mass += boost`. When `mass < threshold`, the note falls off the visible horizon (not deleted, kept in archive). Pure computation — doesn't touch the store; the caller decides what to do with the result.
1. **Temporal mass** (`Gravity` + `Params`): exponential decay by half-life + reinforcement on access + horizon. The khipu app **no longer uses it** (the 2026-07 redesign inverted the physics); pata's quick capture and `iniy-derive` consume it.
2. **Semantic field** (`SemanticField`): per-note vectors over `rimay-verbo-index`, cosine affinity, `nearest` (the basis of khipu's **resonance**), clusters and 2D layout. `local_embed` is the offline trigram fallback.
3. **Cord mass** (`masa_cuerda` + `frescura`): a cord fattens logarithmically with each knot and glows by how recently it last grew. The inverse of forgetting: reinforcement comes from recurrence, not from reading.
## API
```rust
use khipu_gravity::{Gravity, Params};
use khipu_gravity::{masa_cuerda, frescura, FRESCURA_MEDIA_VIDA_SECS};
let g = Gravity::new(Params::default());
let new_mass = g.decay(mass, dt);
let new_mass = g.reinforce(mass, boost);
let thickness = masa_cuerda(n_knots); // 0 when empty; diminishing returns
let glow = frescura(last_knot, now, FRESCURA_MEDIA_VIDA_SECS); // 1.0 just grown → 0.5 after a month
```
## Deps
- `libm` for `exp` (no `std::math` when compiled to WASM)
- Zero I/O or time deps (caller passes `dt`)
- `libm` for `exp`/`log` (fit for future `no_std`)
- `khipu-core` (ids), `rimay-verbo-core`/`-index` (vectors)
+300 -38
View File
@@ -1,22 +1,29 @@
//! `khipu-gravity` — dos físicas, ambas puras y deterministas.
//! `khipu-gravity` — tres físicas, todas puras y deterministas.
//!
//! 1. **Masa temporal** ([`Gravity`] + [`Params`]): cada nota tiene una
//! `mass: f32`. Con el tiempo `decay(mass, dt)` la apaga; con cada
//! acceso `reinforce(mass, boost)` la enciende. Bajo un umbral la
//! nota cae al «archivo» — no se borra, sólo deja el horizonte
//! visible. El cálculo no toca el store; el caller decide qué hacer
//! con el resultado.
//! visible. La usan los consumidores que sí olvidan (la captura
//! rápida de pata, iniy-derive); la app khipu ya no.
//! 2. **Gravedad semántica** ([`SemanticField`]): afinidad coseno entre
//! vectores, vecinos, clústeres por umbral y layout 2D dirigido por
//! fuerzas. Las notas afines se atraen, todas se repelen.
//! vectores y vecinos ([`SemanticField::nearest`]) — la base de la
//! **resonancia**: al capturar un nudo nuevo, sus vecinos son los
//! pensamientos que vuelve a tocar.
//! 3. **Masa de cuerda** ([`masa_cuerda`] + [`frescura`]): una cuerda
//! (idea recurrente) engorda con cada nudo que se le anuda y arde
//! según cuán reciente fue su último crecimiento. Es la inversa del
//! olvido: el refuerzo viene de la recurrencia, no de la lectura.
//!
//! Las dos están desacopladas — un caller puede usar sólo una. El
//! Las tres están desacopladas — un caller puede usar sólo una. El
//! crate compila sin alcanzar `std::f32::exp` (usa [`libm`]) para
//! mantenerse apto a futuros consumos `no_std` (kernel wawa, WASM).
#![forbid(unsafe_code)]
use khipu_core::NoteId;
use rimay_verbo_core::{EmbeddingVector, ModelId};
use rimay_verbo_index::VectorIndex;
use serde::{Deserialize, Serialize};
/// Parámetros de la física temporal de una nota.
@@ -78,11 +85,29 @@ impl Gravity {
}
}
/// Nombre interno del espacio vectorial de un [`SemanticField`]. Da igual cuál
/// sea el embebedor real (trigram local, daemon `verbo`, …): dentro del campo
/// **todos** los vectores comparten índice y por tanto modelo, así que basta un
/// nombre estable; la dimensión se toma del primer vector insertado. El campo no
/// mezcla espacios — igual que khipu, que ya recalcula todo si cambia el modelo.
const FIELD_MODEL_NAME: &str = "khipu-field";
/// Una nube de notas con su vector semántico — el dominio de la gravedad.
///
/// El **almacenamiento** de vectores lo delega en [`VectorIndex<NoteId>`] (el
/// índice unificado de la suite: kNN coseno + persistencia); encima construye
/// grafo/gravedad/clústeres/layout 2D. El índice nace perezoso en el primer
/// `insert` (necesita la dimensión del vector para fijar su [`ModelId`]).
///
/// `order` mantiene el orden de inserción de las claves — el índice es un
/// `HashMap` sin orden garantizado, y `iter`/`clusters`/`gravity_layout` deben
/// ser deterministas. No duplica vectores: sólo las claves.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SemanticField {
/// `(id, vector)` en orden de inserción.
entries: Vec<(NoteId, Vec<f32>)>,
/// Vectores, indexados por `NoteId`. `None` hasta el primer `insert`.
index: Option<VectorIndex<NoteId>>,
/// Claves en orden de inserción, para recorridos deterministas.
order: Vec<NoteId>,
}
/// Posición 2D resultante de una nota tras el layout por gravedad.
@@ -141,40 +166,73 @@ impl SemanticField {
/// Cantidad de notas en el campo.
pub fn len(&self) -> usize {
self.entries.len()
self.index.as_ref().map(VectorIndex::len).unwrap_or(0)
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
self.len() == 0
}
/// Inserta o reemplaza el vector de una nota.
/// Inserta o reemplaza el vector de una nota. El vector se guarda en el
/// [`VectorIndex`]; el índice nace perezoso con la dimensión de este primer
/// vector. Un vector vacío o de dimensión incompatible con el índice ya
/// fijado se ignora (khipu nunca mezcla dimensiones en una sesión).
pub fn insert(&mut self, id: NoteId, vector: Vec<f32>) {
if let Some(slot) = self.entries.iter_mut().find(|(eid, _)| *eid == id) {
slot.1 = vector;
self.insert_etiquetado(id, vector, FIELD_MODEL_NAME);
}
/// Como [`Self::insert`], pero el índice adopta la **etiqueta real del
/// embebedor** (Arco II de anqara, #11): un campo nacido con la etiqueta
/// `"e5-small(384d)"` rechaza vectores de otro embebedor aunque la
/// dimensión coincida — antes el `ModelId` ficticio anulaba esa
/// salvaguarda y dos espacios incomparables se mezclaban sin detectarlo.
/// Devuelve `false` si el vector fue rechazado (etiqueta o dimensión).
pub fn insert_etiquetado(&mut self, id: NoteId, vector: Vec<f32>, etiqueta: &str) -> bool {
if vector.is_empty() {
return false;
}
let index = self
.index
.get_or_insert_with(|| VectorIndex::new(ModelId::new(etiqueta, vector.len())));
// Etiqueta distinta a la del índice ya fijado = otro espacio
// vectorial: se rechaza en vez de corromper.
if index.model().name != etiqueta {
return false;
}
let Ok(ev) = EmbeddingVector::new(index.model().clone(), vector) else {
return false;
};
if index.insert(id, ev).is_ok() {
if !self.order.contains(&id) {
self.order.push(id);
}
true
} else {
self.entries.push((id, vector));
false
}
}
/// Saca la nota del campo. No-op si no estaba. `true` si se removió.
pub fn remove(&mut self, id: NoteId) -> bool {
let before = self.entries.len();
self.entries.retain(|(eid, _)| *eid != id);
self.entries.len() != before
let removed = self.index.as_mut().map(|ix| ix.remove(&id)).unwrap_or(false);
if removed {
self.order.retain(|&eid| eid != id);
}
removed
}
/// Itera `(id, &vector)` en orden de inserción. Útil para serializar
/// el campo completo (persistencia, exportar).
/// el campo completo (persistencia, exportar). El orden lo da `order`
/// porque el índice es un `HashMap` sin orden garantizado.
pub fn iter(&self) -> impl Iterator<Item = (NoteId, &[f32])> {
self.entries.iter().map(|(id, v)| (*id, v.as_slice()))
let index = self.index.as_ref();
self.order
.iter()
.filter_map(move |id| index.and_then(|ix| ix.get(id)).map(|v| (*id, v)))
}
fn vector_of(&self, id: NoteId) -> Option<&[f32]> {
self.entries
.iter()
.find(|(eid, _)| *eid == id)
.map(|(_, v)| v.as_slice())
self.index.as_ref().and_then(|ix| ix.get(&id))
}
/// Afinidad (similitud coseno) entre dos notas. `None` si alguna no
@@ -185,15 +243,29 @@ impl SemanticField {
/// Las `k` notas más afines a `id`, de mayor a menor afinidad.
/// Empata por id ascendente para que el orden sea determinista.
///
/// El kNN coseno lo hace el [`VectorIndex::search`] unificado; aquí sólo se
/// reimpone el desempate determinista por id (el índice no lo garantiza) y
/// se excluye la propia `id`.
pub fn nearest(&self, id: NoteId, k: usize) -> Vec<(NoteId, f32)> {
let Some(base) = self.vector_of(id) else {
let Some(index) = self.index.as_ref() else {
return Vec::new();
};
let mut scored: Vec<(NoteId, f32)> = self
.entries
.iter()
.filter(|(eid, _)| *eid != id)
.filter_map(|(eid, v)| cosine(base, v).map(|s| (*eid, s)))
let Some(base) = index.get(&id) else {
return Vec::new();
};
let Ok(query) = EmbeddingVector::new(index.model().clone(), base.to_vec()) else {
return Vec::new();
};
// top_k = todo (rankeamos todas, luego truncamos con desempate estable);
// min_score = -1.0 no filtra nada (coseno ∈ [-1, 1]), como el original.
let Ok(hits) = index.search(&query, index.len(), -1.0) else {
return Vec::new();
};
let mut scored: Vec<(NoteId, f32)> = hits
.into_iter()
.filter(|h| h.key != id)
.map(|h| (h.key, h.score))
.collect();
scored.sort_by(|a, b| {
b.1.partial_cmp(&a.1)
@@ -204,11 +276,82 @@ impl SemanticField {
scored
}
/// Le da a `id` un domicilio fijo, **una sola vez**: cae en el baricentro
/// de sus parientes semánticos (ponderado por afinidad) y, si quedó pegada
/// a otra nota, se separa apenas. `placed` son las notas ya asentadas con su
/// posición de mundo. Determinista y dependiente sólo de lo ya colocado, así
/// el orden de inserción es estable y el mapa nunca se reacomoda solo.
///
/// Es el anclaje incremental del campo semántico: vivía atrapado en
/// `khipu-app::map::place_note` (frontend); el algoritmo baja aquí para que
/// cualquier frontend lo reuse (Regla 2). El frontend sólo lee posiciones y
/// escribe la resultante en su store.
pub fn anchor_new(&self, id: NoteId, placed: &[(NoteId, (f32, f32))]) -> (f32, f32) {
// Vecinos ya colocados: su afinidad con la nota nueva y su posición.
let mut kin: Vec<(f32, (f32, f32))> = Vec::new();
for (other, pos) in placed {
if *other == id {
continue;
}
let aff = self.affinity(id, *other).unwrap_or(0.0).max(0.0);
kin.push((aff, *pos));
}
let target = if kin.is_empty() {
(0.0, 0.0) // primera nota del cuaderno: centro del mundo.
} else {
let wsum: f32 = kin.iter().map(|(w, _)| *w).sum();
if wsum > 1e-3 {
// Cae junto a su parentela: baricentro ponderado por afinidad.
let (mut tx, mut ty) = (0.0_f32, 0.0_f32);
for (w, (x, y)) in &kin {
tx += w * x;
ty += w * y;
}
(tx / wsum, ty / wsum)
} else {
// Ortogonal a todo: anillo determinista por id, lejos del núcleo.
let ang = id as f32 * ANCHOR_GOLDEN_ANGLE;
let rad = 180.0 + 14.0 * (id as f32).sqrt();
(rad * ang.cos(), rad * ang.sin())
}
};
// Separación: empuja el target hasta despegarlo de cada vecino cercano.
let mut p = target;
for _ in 0..12 {
let mut moved = false;
for (_, q) in &kin {
let dx = p.0 - q.0;
let dy = p.1 - q.1;
let d = (dx * dx + dy * dy).sqrt();
if d < ANCHOR_MIN_SEP {
let (ux, uy) = if d > 1e-3 {
(dx / d, dy / d)
} else {
let a = id as f32 * ANCHOR_GOLDEN_ANGLE;
(a.cos(), a.sin())
};
let push = ANCHOR_MIN_SEP - d;
p.0 += ux * push;
p.1 += uy * push;
moved = true;
}
}
if !moved {
break;
}
}
p
}
/// Agrupa las notas en clústeres: dos notas quedan en el mismo grupo
/// si su afinidad alcanza `threshold` (transitivamente). Cada
/// clúster viene ordenado por id, y la lista de clústeres también.
pub fn clusters(&self, threshold: f32) -> Vec<Vec<NoteId>> {
let n = self.entries.len();
// Necesita **todos** los vectores: los toma del índice en orden estable.
let entries: Vec<(NoteId, &[f32])> = self.iter().collect();
let n = entries.len();
let mut parent: Vec<usize> = (0..n).collect();
fn find(parent: &mut [usize], i: usize) -> usize {
@@ -227,7 +370,7 @@ impl SemanticField {
for i in 0..n {
for j in (i + 1)..n {
let sim = cosine(&self.entries[i].1, &self.entries[j].1).unwrap_or(0.0);
let sim = cosine(entries[i].1, entries[j].1).unwrap_or(0.0);
if sim >= threshold {
let (ri, rj) = (find(&mut parent, i), find(&mut parent, j));
if ri != rj {
@@ -240,7 +383,7 @@ impl SemanticField {
let mut groups: std::collections::BTreeMap<usize, Vec<NoteId>> = Default::default();
for i in 0..n {
let root = find(&mut parent, i);
groups.entry(root).or_default().push(self.entries[i].0);
groups.entry(root).or_default().push(entries[i].0);
}
let mut out: Vec<Vec<NoteId>> = groups.into_values().collect();
for c in &mut out {
@@ -253,12 +396,14 @@ impl SemanticField {
/// Layout 2D por gravedad: las notas afines se atraen, todas se
/// repelen. Determinista — posiciones iniciales en círculo, sin RNG.
pub fn gravity_layout(&self, cfg: &GravityConfig) -> Vec<NotePlacement> {
let n = self.entries.len();
// Layout sobre todos los vectores, en orden de inserción estable.
let entries: Vec<(NoteId, &[f32])> = self.iter().collect();
let n = entries.len();
if n == 0 {
return Vec::new();
}
if n == 1 {
return vec![NotePlacement { id: self.entries[0].0, x: 0.0, y: 0.0 }];
return vec![NotePlacement { id: entries[0].0, x: 0.0, y: 0.0 }];
}
// Posiciones iniciales repartidas en un círculo.
@@ -273,9 +418,7 @@ impl SemanticField {
let mut aff = vec![0.0f32; n * n];
for i in 0..n {
for j in (i + 1)..n {
let s = cosine(&self.entries[i].1, &self.entries[j].1)
.unwrap_or(0.0)
.max(0.0);
let s = cosine(entries[i].1, entries[j].1).unwrap_or(0.0).max(0.0);
aff[i * n + j] = s;
aff[j * n + i] = s;
}
@@ -307,7 +450,7 @@ impl SemanticField {
}
}
self.entries
entries
.iter()
.zip(pos)
.map(|((id, _), (x, y))| NotePlacement { id: *id, x, y })
@@ -315,6 +458,76 @@ impl SemanticField {
}
}
// =====================================================================
// Masa de cuerda — la física de la resonancia
// =====================================================================
/// Vida media por defecto de la frescura de una cuerda: 30 días. Una
/// cuerda que no crece en un mes arde a la mitad; nunca se apaga del
/// todo ([`frescura`] tiene piso en el caller, no aquí).
pub const FRESCURA_MEDIA_VIDA_SECS: f32 = 30.0 * 24.0 * 3600.0;
/// Masa estructural de una cuerda: crece con cada nudo anudado, sin
/// techo duro pero con retornos decrecientes (logarítmico) — una cuerda
/// de 10 nudos no aplasta visualmente a una de 4. `0` nudos = masa `0`.
pub fn masa_cuerda(nudos: usize) -> f32 {
libm::logf(1.0 + nudos as f32)
}
/// Frescura de una cuerda en `[0, 1]`: `1.0` si acaba de crecer
/// (`ultimo_nudo == now`), decae exponencialmente con `media_vida_secs`.
/// No es olvido — la cuerda conserva su masa; sólo modula el brillo
/// («lo que crece arde, lo quieto reposa»). `now` anterior al último
/// nudo o media vida no positiva devuelven `1.0`.
pub fn frescura(ultimo_nudo: u64, now: u64, media_vida_secs: f32) -> f32 {
if now <= ultimo_nudo || media_vida_secs <= 0.0 {
return 1.0;
}
let dt = (now - ultimo_nudo) as f32;
let k = core::f32::consts::LN_2 / media_vida_secs;
libm::expf(-k * dt).clamp(0.0, 1.0)
}
/// Separación mínima entre nodos al anclar uno nuevo (coordenadas de mundo).
pub const ANCHOR_MIN_SEP: f32 = 30.0;
/// Ángulo áureo en radianes — reparte determinísticamente lo que no tiene
/// parentela semántica sin amontonarlo.
const ANCHOR_GOLDEN_ANGLE: f32 = 2.399_963_2;
/// Embedder local de fallback: hash trigram → R^dim con signos +/-1 (random
/// projection 1-bit signed), normalizado por L2. Determinista, independiente de
/// idioma, sin red. Es el vector que alimenta a [`SemanticField`] cuando el
/// daemon de verbo no está; vivía atrapado en `khipu-app::estado::embed`
/// (frontend) y baja aquí, junto a los vectores que produce (Regla 2).
pub fn local_embed(text: &str, dim: usize) -> Vec<f32> {
let mut v = vec![0.0f32; dim];
let lower = text.to_lowercase();
let bytes = lower.as_bytes();
if bytes.len() < 3 {
for (i, b) in bytes.iter().enumerate() {
v[i % dim] += *b as f32 / 255.0;
}
} else {
for w in bytes.windows(3) {
let mut h: u64 = 0xcbf29ce484222325;
for b in w {
h ^= *b as u64;
h = h.wrapping_mul(0x100000001b3);
}
let idx = (h as usize) % dim;
let sign = if h & 1 == 0 { 1.0 } else { -1.0 };
v[idx] += sign;
}
}
let n: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
if n > 0.0 {
for x in &mut v {
*x /= n;
}
}
v
}
#[cfg(test)]
mod tests {
use super::*;
@@ -328,6 +541,19 @@ mod tests {
f
}
#[test]
fn etiqueta_distinta_no_mezcla_espacios() {
// El agujero cerrado en el Arco II #11: misma dimensión, otro
// embebedor → antes se mezclaba sin detectarlo; ahora se rechaza.
let mut f = SemanticField::new();
assert!(f.insert_etiquetado(1, vec![1.0, 0.0, 0.0], "e5-small(384d)"));
assert!(!f.insert_etiquetado(2, vec![0.0, 1.0, 0.0], "mock-3d"));
assert_eq!(f.len(), 1, "el vector del otro espacio no entra");
// El mismo espacio sigue entrando.
assert!(f.insert_etiquetado(2, vec![0.0, 1.0, 0.0], "e5-small(384d)"));
assert_eq!(f.len(), 2);
}
#[test]
fn affinity_is_high_for_aligned_vectors() {
let f = field();
@@ -431,6 +657,42 @@ mod tests {
}
}
#[cfg(test)]
mod cuerda_tests {
use super::*;
#[test]
fn masa_cuerda_grows_with_diminishing_returns() {
assert_eq!(masa_cuerda(0), 0.0);
let m2 = masa_cuerda(2);
let m4 = masa_cuerda(4);
let m8 = masa_cuerda(8);
assert!(m2 > 0.0 && m4 > m2 && m8 > m4);
// Retornos decrecientes: cada nudo nuevo suma menos que el anterior.
assert!(masa_cuerda(5) - masa_cuerda(4) < masa_cuerda(2) - masa_cuerda(1));
}
#[test]
fn frescura_is_full_at_growth_and_halves_at_half_life() {
assert!((frescura(1_000, 1_000, 100.0) - 1.0).abs() < 1e-6);
let f = frescura(0, 100, 100.0);
assert!((f - 0.5).abs() < 1e-3);
}
#[test]
fn frescura_handles_clock_skew_and_bad_half_life() {
// Reloj hacia atrás (RTC roto): frescura plena, nunca panic.
assert_eq!(frescura(2_000, 1_000, 100.0), 1.0);
assert_eq!(frescura(0, 1_000, 0.0), 1.0);
}
#[test]
fn frescura_stays_in_unit_range_long_term() {
let f = frescura(0, 10_000_000_000, FRESCURA_MEDIA_VIDA_SECS);
assert!((0.0..=1.0).contains(&f));
}
}
#[cfg(test)]
mod mass_tests {
use super::*;
+37
View File
@@ -0,0 +1,37 @@
# khipu-share
*Read this in English: [README.md](README.md).*
Compartir notas por la red soberana de tawasuyu sin perder su gravedad local.
Lo que viaja es el **contenido** de la nota (título, cuerpo, etiquetas),
nunca su física temporal: la masa y el último acceso son la atención
*de quien tiene la nota*, no una propiedad transferible. Al importar,
cada nota nace fresca (`mass = 1.0`, `last_access = now`) — su gravedad
arranca en el cuaderno que la recibe.
El sobre es:
- **direccionado por contenido**: su identidad es `BLAKE3(postcard(bundle))`,
así dos sobres con las mismas notas tienen el mismo hash;
- **firmado Ed25519** sobre ese hash con la clave del autor, vía
`agora_core` — verificable sin autoridad central ni red.
```
use agora_core::Keypair;
use khipu_share::{seal, open, SharedNote};
let kp = Keypair::from_seed([7u8; 32]);
let notas = vec![SharedNote {
title: "Receta".into(),
body: "sopa; ver [[Mercado]]".into(),
tags: vec!["cocina".into()],
}];
let sobre = seal(&kp, notas, 1_700_000_000).unwrap();
// El receptor verifica firma + hash antes de confiar en el contenido.
let bundle = open(&sobre).unwrap();
assert_eq!(bundle.notes[0].title, "Receta");
```
---
Parte de **khipu** — ver [khipu](../LEEME.md).
+11
View File
@@ -0,0 +1,11 @@
# khipu-share
Sharing notes over tawasuyu's sovereign network without losing their local gravity.
What travels is the note's **content** (title, body, tags), never its temporal
physics: mass and last access are the attention *of whoever holds the note*, not a
transferable property. On import, the note starts its own gravity.
---
Part of **khipu** — see [khipu](../README.md).
+1 -1
View File
@@ -1,4 +1,4 @@
//! `khipu-share` — compartir notas por la red soberana de gioser sin
//! `khipu-share` — compartir notas por la red soberana de tawasuyu sin
//! perder su gravedad local.
//!
//! Lo que viaja es el **contenido** de la nota (título, cuerpo, etiquetas),
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Generated
+1818 -729
View File
File diff suppressed because it is too large Load Diff
+326 -128
View File
@@ -1,7 +1,13 @@
# Cargo.toml raíz STANDALONE de khipu — front-door sobre Llimphi.
# Solo el código de khipu; Llimphi y lo fundacional por git-dep del monorepo gioser.git.
# Cargo.toml raíz STANDALONE de khipu — 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 = [
"00_unanchay/khipu/khipu-app",
"00_unanchay/khipu/khipu-brahman",
@@ -10,6 +16,11 @@ members = [
"00_unanchay/khipu/khipu-share",
]
# `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"
@@ -20,9 +31,32 @@ publish = false
repository = "https://git.tawasuyu.net/tawasuyu/khipu"
[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"
@@ -49,8 +83,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"] }
@@ -64,10 +102,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"
@@ -82,6 +125,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"
@@ -91,7 +138,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"
@@ -106,7 +153,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"
@@ -125,7 +176,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.
@@ -147,91 +210,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"
@@ -265,7 +401,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"
@@ -290,61 +426,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 = { path = "00_unanchay/khipu/khipu-gravity" }
# === 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"}
@@ -352,6 +519,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"
@@ -360,6 +528,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"
@@ -393,6 +566,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
@@ -421,7 +597,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"
@@ -432,8 +608,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) ===
agora-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" }
agora-keystore = { 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" }
rimay-verbo = { 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) ===
agora-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
agora-keystore = { 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" }
pluma-llm = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
rimay-verbo = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
rimay-verbo-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" }
rimay-verbo-index = { 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"