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:
+46
-48
@@ -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 (V1–V3, 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 V1–V3 → 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
@@ -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 (V1–V3, 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`, V1–V3 → 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(¬as).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(¬e.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
@@ -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, ®ions, 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
|
||||
}
|
||||
@@ -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())),
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`; acá 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
@@ -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 }
|
||||
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¬a).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(¬a) {
|
||||
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(¬a).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(¬a) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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).
|
||||
@@ -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,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
File diff suppressed because it is too large
Load Diff
+326
-128
@@ -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"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user