feat: khipu standalone — notas P2P soberanas content-addressed, local-first (front-door, git-dep al monorepo)

Front-door limpio: solo crates del dominio; Llimphi y lo fundacional por
git-dep del monorepo gioser.git. cargo check pasa (5 crates, 0 errores).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 12:18:02 +00:00
commit bf782ebef1
36 changed files with 13630 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
# khipu
> `khipu` (quechua: nudos de cuerdas para registrar memoria). Notas con gravedad temporal.
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.
## Instalación
```sh
cargo run --release -p khipu-app
```
## Compatibilidad
- **Linux / macOS / Windows** — UI Llimphi (Wayland/X11/Win32 vía `winit`).
- Persistencia local en `$XDG_DATA_HOME/khipu/`.
## 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-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.
## Compartir (agora)
`exportar` sella en `compartido.khipu` un sobre firmado Ed25519 con la identidad del cuaderno y direccionado por su hash BLAKE3 de contenido.
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)
### 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).
### Pendiente
- 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).
## 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.
+30
View File
@@ -0,0 +1,30 @@
# khipu
> `khipu` (Quechua: knotted cord recordkeeping). Notes with temporal gravity.
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.
## Install
```sh
cargo run --release -p khipu-app
```
## Compatibility
- **Linux / macOS / Windows** — Llimphi UI (Wayland/X11/Win32 via `winit`).
- Local persistence in `$XDG_DATA_HOME/khipu/`.
## 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. |
## 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.
+32
View File
@@ -0,0 +1,32 @@
<!-- Quechua (Cusco/Collao). Revisión bienvenida. -->
# khipu
> `khipu` (runa-simi: watasqa wask'akuna yuyanapaq). Qillqakuna pacha tinkuywan.
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.
## Churay
```sh
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.
## 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. |
## 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.
+41
View File
@@ -0,0 +1,41 @@
[package]
name = "khipu-app"
version.workspace = true
edition.workspace = true
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/."
[[bin]]
name = "khipu-app"
path = "src/main.rs"
[[example]]
name = "demo_cli"
path = "examples/demo_cli.rs"
[dependencies]
khipu-core = { path = "../khipu-core" }
khipu-gravity = { path = "../khipu-gravity" }
khipu-share = { path = "../khipu-share" }
khipu-brahman = { path = "../khipu-brahman" }
agora-core = { workspace = true }
rimay-verbo = { 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 }
llimphi-motion = { workspace = true }
llimphi-clipboard = { workspace = true }
app-bus = { workspace = true }
directories = { workspace = true }
postcard = { workspace = true }
serde = { workspace = true }
+17
View File
@@ -0,0 +1,17 @@
# khipu-app
> UI Llimphi sobre el core de [khipu](../README.md). Binario del usuario.
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".
## Uso
```sh
cargo run --release -p khipu-app
```
## 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
+17
View File
@@ -0,0 +1,17 @@
# khipu-app
> Llimphi UI over the core of [khipu](../README.md). 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.
## Usage
```sh
cargo run --release -p khipu-app
```
## 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
@@ -0,0 +1,159 @@
//! `demo_cli` — demostración no gráfica del cuaderno.
//!
//! Siembra un cuaderno personal, imprime el grafo de wiki-links
//! (forward-links, backlinks, huérfanas, enlaces colgantes) y luego la
//! gravedad semántica: los clústeres por afinidad y los vecinos más
//! cercanos de una nota.
//!
//! Los vectores semánticos van a mano —tres tópicos: cocina, jardín,
//! oficina— para que el clustering se vea con claridad. En la app real
//! los produce `verbo`. Corre con
//! `cargo run -p khipu-app --example demo_cli --release`.
use khipu_core::{NoteId, NoteStore};
use khipu_gravity::{Gravity, GravityConfig, Params, SemanticField};
/// Vector de tópico con un leve sesgo — notas del mismo tema quedan
/// afines sin ser idénticas.
fn topic(base: [f32; 3], nudge: f32) -> Vec<f32> {
vec![base[0] + nudge, base[1] + nudge * 0.3, base[2] - nudge * 0.2]
}
fn main() {
let cocina = [1.0, 0.0, 0.0];
let jardin = [0.0, 1.0, 0.0];
let oficina = [0.0, 0.0, 1.0];
let mut store = NoteStore::new();
let mut field = SemanticField::new();
let seed: [(&str, &str, &[&str], Vec<f32>); 7] = [
(
"Índice",
"mi cuaderno: [[Recetas de la abuela]], [[Jardín]] y [[Oficina]]",
&["meta"],
topic(cocina, 0.0),
),
(
"Recetas de la abuela",
"sopa de auyama; ver también [[Lista del mercado]]",
&["cocina"],
topic(cocina, 0.05),
),
(
"Lista del mercado",
"auyama, cilantro, pan; vuelve al [[Índice]]",
&["cocina"],
topic(cocina, 0.10),
),
(
"Jardín",
"riego semanal; las [[Semillas de cilantro]] van en marzo",
&["jardín"],
topic(jardin, 0.05),
),
(
"Semillas de cilantro",
"germinan en diez días",
&["jardín"],
topic(jardin, 0.10),
),
(
"Oficina",
"[[Reunión del lunes]] y pendientes varios",
&["trabajo"],
topic(oficina, 0.05),
),
(
"Diario sin enlaces",
"una nota suelta, no la enlaza nadie y enlaza a [[Algo Perdido]]",
&["personal"],
topic(oficina, 0.50),
),
];
let mut ids: Vec<(NoteId, String)> = Vec::new();
for (title, body, tags, vector) in seed {
let tags = tags.iter().map(|t| t.to_string()).collect();
let id = store.create(title, body, tags, 1_700_000_000);
field.insert(id, vector);
ids.push((id, title.to_string()));
}
let name = |id: NoteId| {
ids.iter()
.find(|(i, _)| *i == id)
.map(|(_, n)| n.as_str())
.unwrap_or("?")
};
println!("\n khipu · cuaderno de notas — {} notas\n", store.len());
println!(" grafo de enlaces:");
for note in store.iter() {
let fwd: Vec<&str> = store.forward_links(note.id).into_iter().map(name).collect();
let back: Vec<&str> = store.backlinks(note.id).into_iter().map(name).collect();
println!(" «{}»", note.title);
println!(" enlaza a : {}", fmt_list(&fwd));
println!(" backlinks : {}", fmt_list(&back));
}
let orphans: Vec<&str> = store.orphans().iter().map(|n| n.title.as_str()).collect();
println!("\n notas huérfanas (sin backlinks): {}", fmt_list(&orphans));
let dangling_owned = store.dangling_links();
let dangling: Vec<&str> = dangling_owned.iter().map(|s| s.as_str()).collect();
println!(" enlaces colgantes (destino inexistente): {}", fmt_list(&dangling));
println!("\n gravedad semántica — clústeres (afinidad ≥ 0.85):");
for (n, cluster) in field.clusters(0.85).iter().enumerate() {
let titles: Vec<&str> = cluster.iter().map(|id| name(*id)).collect();
println!(" grupo {}: {}", n + 1, fmt_list(&titles));
}
let pivot = ids[1].0; // "Recetas de la abuela"
println!("\n vecinos más afines a «{}»:", name(pivot));
for (id, score) in field.nearest(pivot, 3) {
println!(" {:.3} {}", score, name(id));
}
let layout = field.gravity_layout(&GravityConfig::default());
println!("\n layout 2D por gravedad ({} posiciones):", layout.len());
for p in &layout {
println!(" ({:7.1}, {:7.1}) {}", p.x, p.y, name(p.id));
}
println!("\n masa temporal — vida media 7 días, boost 0.4, horizonte 0.10:");
let g = Gravity::new(Params::default());
let pivot_b = ids[4].0; // "Semillas de cilantro"
println!(" «{}» — escenario:", name(pivot_b));
let mut mass = 1.0_f32;
println!(" inicial mass = {:.3}", mass);
mass = g.decay(mass, 3.0 * 24.0 * 3600.0);
println!(
" 3 días sin acceso → decay mass = {:.3} ({})",
mass,
if g.is_visible(mass) { "visible" } else { "archivo" }
);
mass = g.decay(mass, 14.0 * 24.0 * 3600.0);
println!(
" +14 días sin acceso → decay mass = {:.3} ({})",
mass,
if g.is_visible(mass) { "visible" } else { "archivo" }
);
mass = g.reinforce(mass);
println!(
" el usuario abre la nota → reinforce mass = {:.3} ({})",
mass,
if g.is_visible(mass) { "visible" } else { "archivo" }
);
println!();
}
/// Formatea una lista de nombres, o `—` si está vacía.
fn fmt_list(items: &[&str]) -> String {
if items.is_empty() {
"".to_string()
} else {
items.join(", ")
}
}
File diff suppressed because it is too large Load Diff
+655
View File
@@ -0,0 +1,655 @@
//! `map` — el lienzo de pensamientos: geometría de cámara, colocación
//! determinista de notas (anclaje), detección de regiones emergentes y el
//! pintado del mapa (nodos que respiran por masa, filamentos, topónimos).
//!
//! El resto de la app le pide vistas y consultas; los helpers compartidos
//! (`current_mass`, `now_secs`, `button`, `CLUSTER_THRESHOLD`) viven en la
//! raíz y se alcanzan por `crate::`.
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{auto, length, percent, FlexDirection, Position, Rect, Size, Style},
AlignItems, Dimension, JustifyContent,
};
use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Circle as KurboCircle, Stroke};
use llimphi_ui::llimphi_raster::peniko::{Color, Fill};
use llimphi_ui::llimphi_text::{draw_block, Alignment, TextBlock};
use llimphi_ui::{DragPhase, View};
use llimphi_widget_text_input::{text_input_view, TextInputPalette};
use khipu_core::NoteId;
use crate::panels::button;
use crate::{current_mass, now_secs, Focus, Model, Msg, CLUSTER_THRESHOLD};
/// Un nodo del mapa, ya resuelto a coordenadas de mundo + su masa viva.
/// Datos planos para viajar dentro de la closure de pintura.
pub(crate) struct MapNode {
id: NoteId,
/// Coordenadas de mundo (el domicilio fijo de la nota).
x: f32,
y: f32,
/// Masa "vivida" en el instante del render: enciende el brillo y el
/// tamaño. Decae con el tiempo → el mapa respira sin que toques nada.
mass: f32,
/// `false` si cayó bajo el horizonte (sólo se ve con archivo activo).
visible: bool,
color: Color,
label: String,
}
/// Mundo → pantalla local (relativa al rect del lienzo). El centro del
/// rect es el ancla del zoom; `pan` se suma en mundo, luego se escala.
pub(crate) fn world_to_local(wx: f32, wy: f32, w: f32, h: f32, pan: (f32, f32), zoom: f32) -> (f32, f32) {
(w * 0.5 + (wx + pan.0) * zoom, h * 0.5 + (wy + pan.1) * zoom)
}
/// Inversa de [`world_to_local`]: pantalla local → mundo. Para resolver
/// qué nota cae bajo un click.
pub(crate) fn local_to_world(lx: f32, ly: f32, w: f32, h: f32, pan: (f32, f32), zoom: f32) -> (f32, f32) {
let z = zoom.max(1e-3);
((lx - w * 0.5) / z - pan.0, (ly - h * 0.5) / z - pan.1)
}
/// La nota colocada más cercana a un click en coords locales, dentro de un
/// radio de tolerancia (~18 px de pantalla). `None` si el click cae en el
/// vacío — así arrastrar el fondo no cambia la selección.
pub(crate) fn pick_note(model: &Model, lx: f32, ly: f32, w: f32, h: f32) -> Option<NoteId> {
let (wx, wy) = local_to_world(lx, ly, w, h, model.cam_pan, model.cam_zoom);
let now = now_secs();
let mut best: Option<(NoteId, f32)> = None;
for id in &model.order {
let Some(n) = model.store.get(*id) else { continue };
let Some((px, py)) = n.pos else { continue };
if !model.show_archive {
let m = current_mass(&model.gravity, n, now);
if !model.gravity.is_visible(m) {
continue;
}
}
let d2 = (px - wx).powi(2) + (py - wy).powi(2);
if best.map(|(_, bd)| d2 < bd).unwrap_or(true) {
best = Some((*id, d2));
}
}
let tol = (18.0 / model.cam_zoom.max(1e-3)).powi(2);
best.filter(|(_, d2)| *d2 <= tol).map(|(id, _)| id)
}
/// Separación mínima entre nodos al colocarlos (coordenadas de mundo).
pub(crate) const MAP_MIN_SEP: f32 = 30.0;
/// Ángulo áureo en radianes — reparte determinísticamente lo que no tiene
/// parentela semántica sin amontonarlo.
pub(crate) const GOLDEN_ANGLE: f32 = 2.399_963_2;
/// Le da a `id` un domicilio fijo en el mapa, **una sola vez**: cae en el
/// baricentro de sus parientes semánticos (ponderado por afinidad) y, si
/// quedó pegada a otra nota, se separa apenas. Determinista y dependiente
/// sólo de las notas ya asentadas, así el orden de inserción es estable y
/// el mapa nunca se reacomoda solo.
pub(crate) fn place_note(model: &mut Model, id: NoteId) {
if model.store.get(id).map(|n| n.pos.is_some()).unwrap_or(true) {
return; // ya tiene domicilio (o no existe): no se mueve.
}
// Vecinos ya colocados: su afinidad con la nota nueva y su posición.
let mut kin: Vec<(f32, (f32, f32))> = Vec::new();
for other in &model.order {
if *other == id {
continue;
}
let Some(pos) = model.store.get(*other).and_then(|n| n.pos) else { continue };
let aff = model.field.affinity(id, *other).unwrap_or(0.0).max(0.0);
kin.push((aff, pos));
}
let target = if kin.is_empty() {
(0.0, 0.0) // primera nota del cuaderno: centro del mundo.
} else {
let wsum: f32 = kin.iter().map(|(w, _)| *w).sum();
if wsum > 1e-3 {
// Cae junto a su parentela: baricentro ponderado por afinidad.
let (mut tx, mut ty) = (0.0_f32, 0.0_f32);
for (w, (x, y)) in &kin {
tx += w * x;
ty += w * y;
}
(tx / wsum, ty / wsum)
} else {
// Ortogonal a todo: anillo determinista por id, lejos del núcleo.
let ang = id as f32 * GOLDEN_ANGLE;
let rad = 180.0 + 14.0 * (id as f32).sqrt();
(rad * ang.cos(), rad * ang.sin())
}
};
// Separación: empuja el target hasta despegarlo de cada vecino cercano.
let mut p = target;
for _ in 0..12 {
let mut moved = false;
for (_, q) in &kin {
let dx = p.0 - q.0;
let dy = p.1 - q.1;
let d = (dx * dx + dy * dy).sqrt();
if d < MAP_MIN_SEP {
let (ux, uy) = if d > 1e-3 {
(dx / d, dy / d)
} else {
let a = id as f32 * GOLDEN_ANGLE;
(a.cos(), a.sin())
};
let push = MAP_MIN_SEP - d;
p.0 += ux * push;
p.1 += uy * push;
moved = true;
}
}
if !moved {
break;
}
}
model.store.set_pos(id, p.0, p.1);
}
/// Envuelve `child` como cajón absoluto pegado al borde izquierdo, alto
/// completo con un margen. El mapa de fondo sigue recibiendo pan/zoom en
/// el resto de la ventana; sólo los clicks sobre el cajón los come él.
pub(crate) fn overlay_left(child: View<Msg>, width: f32) -> View<Msg> {
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(8.0_f32),
top: length(8.0_f32),
bottom: length(8.0_f32),
right: auto(),
},
size: Size {
width: length(width),
height: auto(),
},
flex_direction: FlexDirection::Column,
..Default::default()
})
.children(vec![child])
}
/// Columna interna del editor: barra de cierre (× ⇒ deselecciona) arriba +
/// el editor abajo, sobre `bg_panel`. La comparten el overlay lateral y la
/// tarjeta anclada del zoom semántico.
pub(crate) fn editor_shell(child: View<Msg>, theme: &Theme) -> View<Msg> {
let close = button("× cerrar", theme.bg_button, theme.fg_muted, Msg::Deselect);
let close_row = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(28.0_f32),
},
flex_shrink: 0.0,
justify_content: Some(JustifyContent::End),
align_items: Some(AlignItems::Center),
padding: Rect {
left: length(6.0_f32),
right: length(6.0_f32),
top: length(2.0_f32),
bottom: length(2.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel_alt)
.children(vec![close]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel)
.children(vec![close_row, child])
}
/// Envuelve `child` como panel absoluto pegado al borde derecho, alto
/// completo, con barra de cierre. El editor del nodo abierto cuando se lo
/// edita de lejos (zoom bajo): un fallback práctico al anclaje in-situ.
pub(crate) fn overlay_right(child: View<Msg>, width: f32, theme: &Theme) -> View<Msg> {
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: auto(),
top: length(8.0_f32),
bottom: length(8.0_f32),
right: length(8.0_f32),
},
size: Size {
width: length(width),
height: auto(),
},
flex_direction: FlexDirection::Column,
..Default::default()
})
.children(vec![editor_shell(child, theme)])
}
/// Mundo → pantalla local usando el último tamaño de lienzo conocido + la
/// cámara. La versión de `view()`, donde el rect real aún no se sabe.
pub(crate) fn world_screen(model: &Model, wx: f32, wy: f32) -> (f32, f32) {
let (w, h) = model.canvas_size;
world_to_local(wx, wy, w, h, model.cam_pan, model.cam_zoom)
}
/// Posición de pantalla (local al lienzo) del nodo `id`. `None` si la nota
/// no tiene domicilio todavía.
pub(crate) fn node_screen_pos(model: &Model, id: NoteId) -> Option<(f32, f32)> {
let (wx, wy) = model.store.get(id).and_then(|n| n.pos)?;
Some(world_screen(model, wx, wy))
}
/// Mínimo de notas para que un clúster cuente como región candidata.
pub(crate) const REGION_MIN_MEMBERS: usize = 3;
/// Distancia de mundo dentro de la cual una región ya "posee" un clúster:
/// si hay un topónimo así de cerca del centroide, no se vuelve a ofrecer.
pub(crate) const REGION_MATCH_DIST: f32 = 140.0;
/// Centroides (mundo) de los clústeres densos que todavía no tienen una
/// región cerca — los lugares que el mapa ofrece bautizar. Sólo cuentan
/// miembros colocados y visibles.
pub(crate) fn unnamed_cluster_centroids(model: &Model) -> Vec<(f32, f32)> {
let now = now_secs();
let mut out = Vec::new();
for cluster in model.field.clusters(CLUSTER_THRESHOLD) {
let pts: Vec<(f32, f32)> = cluster
.iter()
.filter_map(|id| {
let n = model.store.get(*id)?;
let p = n.pos?;
let m = current_mass(&model.gravity, n, now);
(model.show_archive || model.gravity.is_visible(m)).then_some(p)
})
.collect();
if pts.len() < REGION_MIN_MEMBERS {
continue;
}
let (sx, sy) = pts.iter().fold((0.0, 0.0), |(ax, ay), (x, y)| (ax + x, ay + y));
let c = (sx / pts.len() as f32, sy / pts.len() as f32);
let d2 = REGION_MATCH_DIST * REGION_MATCH_DIST;
let near_named = model
.regions
.iter()
.any(|r| (r.x - c.0).powi(2) + (r.y - c.1).powi(2) <= d2);
let naming_here = model
.naming
.map(|(nx, ny)| (nx - c.0).powi(2) + (ny - c.1).powi(2) <= d2)
.unwrap_or(false);
if !near_named && !naming_here {
out.push(c);
}
}
out
}
/// Chip clickeable "✛ nombrar zona" que ofrece bautizar el clúster denso
/// en `(wx, wy)`. Al click abre el input de bautizo en esa coordenada.
pub(crate) fn name_region_chip(wx: f32, wy: f32, theme: &Theme) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(theme.bg_button)
.radius(12.0)
.hover_fill(theme.bg_button_hover)
.text_aligned("✛ nombrar zona", 11.0, theme.fg_muted, Alignment::Center)
.on_click(Msg::BeginNaming(wx, wy))
}
/// Mini-input del bautizo en curso: una tarjeta con el campo de texto
/// enfocado. Enter confirma, Esc cancela (en `on_key`).
pub(crate) fn naming_input(model: &Model, input_palette: &TextInputPalette) -> View<Msg> {
let input = text_input_view(
&model.region_input,
"nombre de la zona…",
model.focus == Focus::Region,
input_palette,
Msg::Focus(Focus::Region),
);
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
align_items: Some(AlignItems::Center),
padding: Rect {
left: length(6.0_f32),
right: length(6.0_f32),
top: length(4.0_f32),
bottom: length(4.0_f32),
},
..Default::default()
})
.fill(model.theme.bg_panel)
.radius(8.0)
.children(vec![input])
}
/// Posiciona `child` como vista absoluta de tamaño `(w, h)` centrada en la
/// pantalla `(sx, sy)`, clampeada al lienzo. Para chips y mini-inputs que
/// viven en el mapa (sugerencia de bautizo, input de nombre).
pub(crate) fn pinned(child: View<Msg>, sx: f32, sy: f32, w: f32, h: f32, canvas: (f32, f32)) -> View<Msg> {
let left = (sx - w * 0.5).clamp(4.0, (canvas.0 - w - 4.0).max(4.0));
let top = (sy - h * 0.5).clamp(4.0, (canvas.1 - h - 4.0).max(4.0));
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(left),
top: length(top),
right: auto(),
bottom: auto(),
},
size: Size {
width: length(w),
height: length(h),
},
..Default::default()
})
.children(vec![child])
}
/// La tarjeta del nodo abierto, anclada a su coordenada `(nx, ny)` de
/// pantalla: el zoom semántico hecho carne — el editor vive EN el lugar del
/// pensamiento, no en un panel aparte. Se clampea para no salirse del
/// lienzo. Hija del canvas, así pan/zoom la arrastran con el nodo.
pub(crate) fn node_card(child: View<Msg>, nx: f32, ny: f32, canvas: (f32, f32), theme: &Theme) -> View<Msg> {
let (cw_max, ch_max) = canvas;
let cw = 380.0_f32.min((cw_max - 16.0).max(220.0));
let ch = 440.0_f32.min((ch_max - 16.0).max(200.0));
// Anclada bajo el nodo, centrada en X, clampeada a la ventana.
let left = (nx - cw * 0.5).clamp(8.0, (cw_max - cw - 8.0).max(8.0));
let top = (ny + 16.0).clamp(8.0, (ch_max - ch - 8.0).max(8.0));
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(left),
top: length(top),
right: auto(),
bottom: auto(),
},
size: Size {
width: length(cw),
height: length(ch),
},
flex_direction: FlexDirection::Column,
..Default::default()
})
.radius(8.0)
.children(vec![editor_shell(child, theme)])
}
pub(crate) fn gravity_panel(model: &Model, injected: Vec<View<Msg>>) -> View<Msg> {
let theme = model.theme;
let now = now_secs();
let clusters = model.field.clusters(CLUSTER_THRESHOLD);
let selected = model.selected;
let pan = model.cam_pan;
let zoom = model.cam_zoom;
// Nodos colocados (los que ya tienen domicilio), con su masa viva.
let mut nodes: Vec<MapNode> = Vec::new();
for id in &model.order {
let Some(n) = model.store.get(*id) else { continue };
let Some((x, y)) = n.pos else { continue };
let mass = current_mass(&model.gravity, n, now);
let visible = model.gravity.is_visible(mass);
if !visible && !model.show_archive {
continue;
}
nodes.push(MapNode {
id: *id,
x,
y,
mass,
visible,
color: cluster_color(*id, &clusters, theme),
label: short_label(&n.title),
});
}
// Topónimos: las regiones bautizadas, para pintarlas como rótulos de
// continente detrás de los nodos.
let regions: Vec<(String, f32, f32)> = model
.regions
.iter()
.map(|r| (r.name.clone(), r.x, r.y))
.collect();
// Filamentos del nodo seleccionado: sus parientes más afines ya
// colocados. Elegir un pensamiento enciende sus vecinos (activación
// por difusión) — el motor de serendipia.
let mut links: Vec<((f32, f32), (f32, f32), f32)> = Vec::new();
if let Some(sel) = selected {
if let Some(sp) = model.store.get(sel).and_then(|n| n.pos) {
for (nid, aff) in model.field.nearest(sel, 6) {
if aff < 0.20 {
continue;
}
if let Some(np) = model.store.get(nid).and_then(|n| n.pos) {
links.push((sp, np, aff));
}
}
}
}
let canvas = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel_alt)
.paint_with(move |scene, ts, rect| {
paint_map(scene, ts, rect, &nodes, &links, &regions, selected, pan, zoom, theme);
})
.draggable_at(|phase, dx, dy, _lx, _ly| match phase {
DragPhase::Move => Some(Msg::MapPan(dx, dy)),
DragPhase::End => None,
})
.on_scroll(|_dx, dy| Some(Msg::MapZoom(dy)))
.on_click_at(|lx, ly, w, h| Some(Msg::MapClick(lx, ly, w, h)))
// La tarjeta del nodo abierto (zoom semántico) viaja como hija del
// canvas: se pinta encima de los nodos y la cámara la arrastra con el
// pensamiento al que pertenece.
.children(injected);
View::new(Style {
size: Size {
width: Dimension::auto(),
height: percent(1.0_f32),
},
flex_grow: 1.0,
flex_basis: length(0.0_f32),
min_size: Size {
width: length(0.0_f32),
height: length(0.0_f32),
},
padding: Rect {
left: length(4.0_f32),
right: length(4.0_f32),
top: length(4.0_f32),
bottom: length(4.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel)
.children(vec![canvas])
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn paint_map(
scene: &mut llimphi_ui::llimphi_raster::vello::Scene,
ts: &mut llimphi_ui::llimphi_text::Typesetter,
rect: llimphi_ui::PaintRect,
nodes: &[MapNode],
links: &[((f32, f32), (f32, f32), f32)],
regions: &[(String, f32, f32)],
selected: Option<NoteId>,
pan: (f32, f32),
zoom: f32,
theme: Theme,
) {
if rect.w <= 0.0 || rect.h <= 0.0 {
return;
}
// Pantalla absoluta = origen del rect + pantalla local.
let to_screen = |wx: f32, wy: f32| -> (f64, f64) {
let (lx, ly) = world_to_local(wx, wy, rect.w, rect.h, pan, zoom);
((rect.x + lx) as f64, (rect.y + ly) as f64)
};
// Topónimos al fondo: el nombre de cada región, grande y tenue, como
// rótulo de continente; un halo suave insinúa su territorio.
for (name, rx, ry) in regions {
let (cx, cy) = to_screen(*rx, *ry);
let blob = KurboCircle::new((cx, cy), (96.0 * zoom as f64).max(34.0));
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
with_alpha(theme.accent, 0.05),
None,
&blob,
);
let size = (15.0 * zoom).clamp(11.0, 28.0);
// Centrado aproximado: `simple` alinea a la izquierda en (x, y).
let est_w = name.chars().count() as f64 * size as f64 * 0.52;
draw_block(
scene,
ts,
&TextBlock::simple(
name,
size,
with_alpha(theme.fg_text, 0.30),
(cx - est_w * 0.5, cy - size as f64 * 0.6),
),
);
}
// Filamentos primero (debajo de los nodos). Más opacos cuanto más afín.
for (a, b, aff) in links {
let (ax, ay) = to_screen(a.0, a.1);
let (bx, by) = to_screen(b.0, b.1);
let mut path = BezPath::new();
path.move_to((ax, ay));
path.line_to((bx, by));
let alpha = (0.18 + aff * 0.55).clamp(0.0, 0.85);
scene.stroke(
&Stroke::new((0.8 + *aff as f64 * 1.6).max(0.6)),
Affine::IDENTITY,
with_alpha(theme.accent, alpha),
None,
&path,
);
}
// Nodos: tamaño y brillo crecen con la masa viva (el mapa respira).
for n in nodes {
let (px, py) = to_screen(n.x, n.y);
let m = n.mass.clamp(0.0, 2.0);
// Radio base por masa, escalado apenas por zoom para no inflarse.
let r = (3.0 + m * 4.5) * (0.6 + 0.4 * zoom.clamp(0.5, 1.5));
// Brillo: las notas frescas arden; las que se enfrían se apagan
// hacia el fondo. Bajo el horizonte (archivo) van casi transparentes.
let glow = if n.visible {
(0.35 + m * 0.45).clamp(0.0, 1.0)
} else {
0.18
};
let color = with_alpha(n.color, glow);
// Halo tenue alrededor de las notas más encendidas.
if n.visible && m > 0.6 {
let halo = KurboCircle::new((px, py), (r + 5.0) as f64);
scene.fill(Fill::NonZero, Affine::IDENTITY, with_alpha(n.color, 0.10), None, &halo);
}
let circle = KurboCircle::new((px, py), r as f64);
scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &circle);
if selected == Some(n.id) {
let ring = KurboCircle::new((px, py), (r + 3.0) as f64);
scene.stroke(&Stroke::new(2.0), Affine::IDENTITY, theme.accent, None, &ring);
}
// Etiqueta: sólo si el zoom da espacio o es la seleccionada — para
// no saturar el mapa lejano. El texto sale del Typesetter.
if (zoom >= 0.9 || selected == Some(n.id)) && n.visible {
let lbl_col = with_alpha(theme.fg_text, (glow + 0.25).clamp(0.0, 1.0));
draw_block(
scene,
ts,
&TextBlock::simple(&n.label, 10.0, lbl_col, (px + r as f64 + 4.0, py - 7.0)),
);
}
}
}
pub(crate) fn cluster_color(id: NoteId, clusters: &[Vec<NoteId>], theme: Theme) -> Color {
let idx = clusters.iter().position(|c| c.contains(&id)).unwrap_or(0);
// Paleta tomada del theme + matices generados por golden-ratio
// sobre el hue del accent. Determinista por índice.
let palette: [Color; 6] = [
theme.accent,
with_alpha(rotate_hue(theme.accent, 0.16), 1.0),
with_alpha(rotate_hue(theme.accent, 0.33), 1.0),
with_alpha(rotate_hue(theme.accent, 0.50), 1.0),
with_alpha(rotate_hue(theme.accent, 0.66), 1.0),
with_alpha(rotate_hue(theme.accent, 0.83), 1.0),
];
palette[idx % palette.len()]
}
pub(crate) fn with_alpha(c: Color, alpha: f32) -> Color {
let [r, g, b, _] = c.components;
Color::new([r, g, b, alpha])
}
pub(crate) fn rotate_hue(c: Color, dh: f32) -> Color {
// RGB → HSV → rota H → RGB. Aproximación, alpha fijo.
let [r, g, b, a] = c.components;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let v = max;
let s = if max <= 0.0 { 0.0 } else { (max - min) / max };
let h = if (max - min).abs() < 1e-6 {
0.0
} else if max == r {
((g - b) / (max - min)) % 6.0
} else if max == g {
(b - r) / (max - min) + 2.0
} else {
(r - g) / (max - min) + 4.0
};
let h2 = ((h / 6.0) + dh).rem_euclid(1.0) * 6.0;
let c2 = v * s;
let x = c2 * (1.0 - ((h2 % 2.0) - 1.0).abs());
let (r2, g2, b2) = match h2 as i32 {
0 => (c2, x, 0.0),
1 => (x, c2, 0.0),
2 => (0.0, c2, x),
3 => (0.0, x, c2),
4 => (x, 0.0, c2),
_ => (c2, 0.0, x),
};
let m = v - c2;
Color::new([r2 + m, g2 + m, b2 + m, a])
}
pub(crate) fn short_label(s: &str) -> String {
let mut out: String = s.chars().take(24).collect();
if s.chars().count() > 24 {
out.push('…');
}
out
}
+226
View File
@@ -0,0 +1,226 @@
//! `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.
use std::sync::Arc;
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};
pub(crate) fn export_notebook(model: &Model) -> String {
let Some(kp) = model.keypair.as_ref() else {
return "sin identidad para firmar".into();
};
let Some(dir) = khipu_dir() else {
return "sin directorio de datos".into();
};
// Compartir selectivo: si hay texto en el buscador, exportamos sólo
// las notas que filtra (mismo criterio que la lista); si está vacío,
// todo el cuaderno.
let query = model.search.text();
let q = query.trim();
let notes: Vec<SharedNote> = model
.order
.iter()
.filter_map(|id| model.store.get(*id))
.filter(|n| q.is_empty() || note_matches(n, q))
.map(SharedNote::from_note)
.collect();
if notes.is_empty() {
return "no hay notas para exportar (¿el filtro no coincide?)".into();
}
let n = notes.len();
let sobre = match khipu_share::seal(kp, notes, now_secs()) {
Ok(s) => s,
Err(_) => return "falló el sellado".into(),
};
let Ok(bytes) = sobre.to_bytes() else {
return "falló serializar el sobre".into();
};
let path = dir.join("compartido.khipu");
let tmp = path.with_extension("khipu.tmp");
if std::fs::write(&tmp, &bytes)
.and_then(|_| std::fs::rename(&tmp, &path))
.is_err()
{
return "no se pudo escribir el sobre".into();
}
let hash = sobre.content_address().unwrap_or([0u8; 32]);
let filtro = if q.is_empty() {
String::new()
} else {
format!(" (filtro «{q}»)")
};
format!(
"exportadas {n} notas{filtro} → compartido.khipu · {}",
hex8(&hash)
)
}
/// Verifica e ingiere `compartido.khipu`. Las notas nuevas nacen con
/// gravedad fresca; sus embeddings se recalculan en segundo plano. Un
/// sobre con firma inválida se rechaza entero. Devuelve la línea de estado.
pub(crate) fn import_notebook(model: &mut Model, h: &Handle<Msg>) -> String {
let Some(dir) = khipu_dir() else {
return "sin directorio de datos".into();
};
let path = dir.join("compartido.khipu");
let Ok(bytes) = std::fs::read(&path) else {
return "no hay compartido.khipu para importar".into();
};
let sobre = match SignedBundle::from_bytes(&bytes) {
Ok(s) => s,
Err(_) => return "sobre ilegible".into(),
};
let outcome = match khipu_share::open(&sobre) {
Ok(bundle) => khipu_share::import_into(&mut model.store, bundle, now_secs()),
Err(_) => return "firma inválida — sobre rechazado".into(),
};
for id in &outcome.created {
model.order.push(*id);
schedule_embedding(model, *id, h);
}
format!(
"importadas {} · omitidas {} (ya existían)",
outcome.created.len(),
outcome.skipped
)
}
/// Dirección donde el servidor escucha. `KHIPU_BIND` la sobrescribe;
/// default localhost para no exponerse sin querer.
pub(crate) fn bind_addr() -> String {
std::env::var("KHIPU_BIND").unwrap_or_else(|_| "127.0.0.1:7700".into())
}
/// Dirección del par a quien jalarle el cuaderno. `KHIPU_PEER` la
/// sobrescribe; default coincide con [`bind_addr`] para probar en local.
pub(crate) fn peer_addr() -> String {
std::env::var("KHIPU_PEER").unwrap_or_else(|_| "127.0.0.1:7700".into())
}
/// Arma el nodo libp2p la primera vez que se necesita: runtime tokio
/// dedicado + `KhipuNode` que empieza a escuchar (para ser alcanzable y
/// obtener nuestra dirección de marcado). Idempotente. `false` si no se
/// pudo (sin runtime o sin red).
pub(crate) fn ensure_p2p(model: &mut Model) -> bool {
if model.p2p.is_some() {
return true;
}
let Ok(rt) = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
else {
return false;
};
// `KhipuNode::standalone` arranca el swarm con `tokio::spawn`: hay que
// estar dentro del runtime.
let node = {
let _g = rt.enter();
match khipu_brahman::KhipuNode::standalone() {
Ok(n) => Arc::new(n),
Err(_) => return false,
}
};
let dial_addr = rt
.block_on(node.listen_str("/ip4/0.0.0.0/tcp/0"))
.unwrap_or_default();
// Si hay un nodo bootstrap configurado, nos unimos a la malla DHT para
// poder descubrir y ser descubiertos (`anunciar`/`descubrir`).
if let Ok(boot) = std::env::var("KHIPU_BOOTSTRAP") {
let _ = node.dial_str(&boot);
}
model.p2p = Some(P2p {
rt: Arc::new(rt),
node,
dial_addr,
serving: false,
});
true
}
/// Levanta (una sola vez) el servidor TCP que sirve `compartido.khipu`.
/// El hilo lee el archivo en cada conexión, así sirve siempre la versión
/// vigente; vive hasta que el proceso termina. Devuelve la línea de estado.
pub(crate) fn start_publishing(model: &mut Model, h: &Handle<Msg>) -> String {
if model.publishing {
return format!("ya publicando en {}", bind_addr());
}
let Some(dir) = khipu_dir() else {
return "sin directorio de datos".into();
};
let addr = bind_addr();
let listener = match std::net::TcpListener::bind(&addr) {
Ok(l) => l,
Err(e) => return format!("no se pudo escuchar en {addr}: {e}"),
};
// Puerto efectivo (resuelve `:0` si se usara) para anunciarlo en la baliza.
let tcp_port = listener.local_addr().map(|a| a.port()).unwrap_or(0);
let path = dir.join("compartido.khipu");
std::thread::spawn(move || {
khipu_share::net::serve_loop(listener, move || std::fs::read(&path));
});
// Baliza periódica para que los pares nos descubran sin saber la IP.
let beacon = khipu_share::discovery::Beacon {
author: model.keypair.as_ref().map(|k| k.public_key()).unwrap_or([0u8; 32]),
port: tcp_port,
name: "khipu".into(),
};
std::thread::spawn(move || loop {
let _ = khipu_share::discovery::anunciar(&beacon);
std::thread::sleep(std::time::Duration::from_secs(2));
});
model.publishing = true;
// Además del TCP/LAN, servimos por libp2p (cifrado, WAN). El nodo se
// arma perezoso; servimos `compartido.khipu` y nos anunciamos en la DHT.
let p2p_status = if ensure_p2p(model) {
let dir2 = dir.clone();
if let Some(p) = model.p2p.as_mut() {
if !p.serving {
let path2 = dir2.join("compartido.khipu");
let node = p.node.clone();
let _g = p.rt.enter();
node.run_serve(move || std::fs::read(&path2).ok());
node.anunciar();
p.serving = true;
}
// Si hay un relay configurado (KHIPU_RELAY=/ip4/.../p2p/<id>),
// reservamos un circuito ahí para ser alcanzables detrás de NAT.
// Async (dial + identify + reserva tardan ~2s): cuando termina,
// reentra con Msg::RelayReady para mostrar la dirección.
if let Ok(relay) = std::env::var("KHIPU_RELAY") {
let (rt, node, h2) = (p.rt.clone(), p.node.clone(), h.clone());
rt.spawn(async move {
let _ = node.dial_str(&relay);
// Esperamos a que AutoNAT confirme la dirección del relay
// (boot_delay + dial-back) antes de pedir la reserva.
tokio::time::sleep(std::time::Duration::from_secs(6)).await;
let circuit = format!("{relay}/p2p-circuit");
let msg = match node.listen_str(&circuit).await {
Ok(addr) => addr,
Err(e) => format!("falló reservar circuito: {e}"),
};
h2.dispatch(Msg::RelayReady(msg));
});
}
format!(" · libp2p: {}", p.dial_addr)
} else {
String::new()
}
} else {
String::new()
};
format!("publicando en {addr} (LAN){p2p_status}")
}
/// Prefijo hex (4 bytes / 8 hex) de un hash, para mostrar una dirección
/// de contenido sin abrumar.
pub(crate) fn hex8(hash: &[u8; 32]) -> String {
hash[..4].iter().map(|b| format!("{b:02x}")).collect()
}
+728
View File
@@ -0,0 +1,728 @@
//! `panels` — los constructores de vistas en flujo: cabecera, cajón de
//! notas (lista + búsqueda), panel de recibir (pares P2P), y el editor de
//! la nota (título/cuerpo/etiquetas + stats). Frontends puros sobre el
//! `Model`; la lógica vive en la raíz y en `map`.
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Rect, Size, Style},
AlignItems, Dimension, JustifyContent,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
use llimphi_widget_list::{list_view, ListPalette, ListRow, ListSpec};
use llimphi_widget_text_editor::{text_editor_view, EditorMetrics, EditorPalette};
use llimphi_widget_text_input::{text_input_view, TextInputPalette};
use khipu_core::{Note, NoteId};
use crate::{
current_mass, now_secs, Focus, Model, Msg, EDITOR_VISIBLE_LINES, FIELD_LABEL_SIZE, HEADER_H,
LIST_WIDTH, ROW_H,
};
pub(crate) fn header_view(model: &Model) -> View<Msg> {
let title = format!("khipu · {} notas", model.store.len());
let title_node = View::new(Style {
size: Size {
width: Dimension::auto(),
height: percent(1.0_f32),
},
flex_grow: 1.0,
padding: Rect {
left: length(14.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(title, 14.0, model.theme.fg_text, Alignment::Start);
let list_label = if model.show_list { "ocultar notas" } else { "☰ notas" };
let list_btn = button(
list_label,
model.theme.bg_button,
if model.show_list { model.theme.accent } else { model.theme.fg_muted },
Msg::ToggleList,
);
let new_btn = button(
"+ nueva (Ctrl+N)",
model.theme.bg_button,
model.theme.fg_text,
Msg::NewNote,
);
let archive_label = if model.show_archive {
"ocultar archivo"
} else {
"ver archivo"
};
let archive_btn = button(
archive_label,
model.theme.bg_button,
model.theme.fg_muted,
Msg::ToggleArchive,
);
let del_btn = button(
"borrar",
model.theme.bg_button,
model.theme.fg_muted,
Msg::DeleteSelected,
);
let export_btn = button(
"exportar",
model.theme.bg_button,
model.theme.fg_muted,
Msg::Export,
);
let import_btn = button(
"importar",
model.theme.bg_button,
model.theme.fg_muted,
Msg::Import,
);
let publish_label = if model.publishing {
"publicando"
} else {
"publicar"
};
let publish_btn = button(
publish_label,
model.theme.bg_button,
if model.publishing {
model.theme.accent
} else {
model.theme.fg_muted
},
Msg::Publish,
);
let receive_btn = button(
"recibir",
model.theme.bg_button,
model.theme.fg_muted,
Msg::Receive,
);
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(HEADER_H),
},
flex_shrink: 0.0,
padding: Rect {
left: length(0.0_f32),
right: length(10.0_f32),
top: length(4.0_f32),
bottom: length(4.0_f32),
},
align_items: Some(AlignItems::Center),
gap: Size {
width: length(8.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.fill(model.theme.bg_panel_alt)
.children(vec![
title_node,
list_btn,
new_btn,
archive_btn,
del_btn,
export_btn,
import_btn,
publish_btn,
receive_btn,
])
}
pub(crate) fn button(label: &str, bg: Color, fg: Color, msg: Msg) -> View<Msg> {
// El ancho crece con el largo del texto — los labels más
// explícitos («+ nueva (Ctrl+N)», «ocultar archivo») piden más
// espacio que un «borrar» seco.
let chars = label.chars().count() as f32;
let width = (chars * 7.2 + 22.0).max(86.0);
View::new(Style {
size: Size {
width: length(width),
height: length(26.0_f32),
},
padding: Rect {
left: length(10.0_f32),
right: length(10.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(bg)
.radius(4.0)
.text_aligned(label.to_string(), 11.0, fg, Alignment::Center)
.on_click(msg)
}
pub(crate) fn list_panel(
model: &Model,
palette: &ListPalette,
input_palette: &TextInputPalette,
) -> View<Msg> {
let now = now_secs();
let query = model.search.text();
let q = query.trim();
// Particionamos en horizonte vs archivo y ordenamos cada parte por
// masa viva decreciente. Si hay query, ambas listas quedan
// pre-filtradas por coincidencia en título/cuerpo/etiquetas.
let mut visible: Vec<(NoteId, f32, &Note)> = Vec::new();
let mut archive: Vec<(NoteId, f32, &Note)> = Vec::new();
let mut hidden_by_query = 0usize;
for id in &model.order {
let Some(n) = model.store.get(*id) else {
continue;
};
if !q.is_empty() && !note_matches(n, q) {
hidden_by_query += 1;
continue;
}
let m = current_mass(&model.gravity, n, now);
if model.gravity.is_visible(m) {
visible.push((*id, m, n));
} else {
archive.push((*id, m, n));
}
}
let by_mass_desc = |a: &(NoteId, f32, &Note), b: &(NoteId, f32, &Note)| {
b.1.partial_cmp(&a.1)
.unwrap_or(core::cmp::Ordering::Equal)
.then(a.0.cmp(&b.0))
};
visible.sort_by(by_mass_desc);
archive.sort_by(by_mass_desc);
let mut chain: Vec<(NoteId, f32, &Note)> = visible.clone();
if model.show_archive {
chain.extend(archive.iter().cloned());
}
let rows: Vec<ListRow<Msg>> = chain
.into_iter()
.map(|(id, mass, n)| ListRow {
label: row_label(n, mass),
selected: Some(id) == model.selected,
on_click: Msg::SelectNote(id),
})
.collect();
let caption = if !q.is_empty() {
format!(
"buscar «{}» · {}/{} coinciden",
q,
visible.len() + if model.show_archive { archive.len() } else { 0 },
visible.len() + archive.len() + hidden_by_query
)
} else if archive.is_empty() {
format!("notas · {}", visible.len())
} else if model.show_archive {
format!(
"notas · {} horizonte + {} archivo",
visible.len(),
archive.len()
)
} else {
format!(
"notas · {} horizonte (+{} archivo)",
visible.len(),
archive.len()
)
};
let spec = ListSpec {
total: rows.len(),
rows,
caption: Some(caption),
truncated_hint: None,
row_height: ROW_H,
palette: *palette,
};
let search_input = text_input_view(
&model.search,
"buscar (título, cuerpo, etiquetas)",
model.focus == Focus::Search,
input_palette,
Msg::Focus(Focus::Search),
);
let search_row = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(28.0_f32),
},
flex_shrink: 0.0,
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_alt)
.children(vec![search_input]);
let list_wrap = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
flex_grow: 1.0,
flex_basis: length(0.0_f32),
min_size: Size {
width: length(0.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(vec![list_view(spec)]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: length(LIST_WIDTH),
height: percent(1.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.children(vec![search_row, list_wrap])
}
/// Panel izquierdo en modo "recibir": arriba un input de dirección manual
/// (`host:puerto`, habilita WAN) con botones jalar/cancelar; debajo, la
/// lista de pares descubiertos en la LAN (click ⇒ jalar de él). Reemplaza
/// transitoriamente la lista de notas.
pub(crate) fn receive_panel(
model: &Model,
palette: &ListPalette,
input_palette: &TextInputPalette,
) -> View<Msg> {
// Fila de dirección manual + jalar.
let addr_input = text_input_view(
&model.peer_input,
"host:puerto o /ip4/…/p2p/…",
model.focus == Focus::PeerAddr,
input_palette,
Msg::Focus(Focus::PeerAddr),
);
let addr_wrap = View::new(Style {
size: Size {
width: Dimension::auto(),
height: length(26.0_f32),
},
flex_grow: 1.0,
..Default::default()
})
.children(vec![addr_input]);
let jalar = button(
"jalar",
model.theme.bg_button,
model.theme.accent,
Msg::FetchManual,
);
let addr_row = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(34.0_f32),
},
flex_shrink: 0.0,
padding: Rect {
left: length(6.0_f32),
right: length(6.0_f32),
top: length(4.0_f32),
bottom: length(4.0_f32),
},
align_items: Some(AlignItems::Center),
gap: Size {
width: length(6.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.fill(model.theme.bg_panel_alt)
.children(vec![addr_wrap, jalar]);
let cancel = button(
"cancelar",
model.theme.bg_button,
model.theme.fg_muted,
Msg::CancelPeers,
);
let cancel_row = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(34.0_f32),
},
flex_shrink: 0.0,
padding: Rect {
left: length(6.0_f32),
right: length(6.0_f32),
top: length(2.0_f32),
bottom: length(4.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.fill(model.theme.bg_panel_alt)
.children(vec![cancel]);
let rows: Vec<ListRow<Msg>> = model
.peers
.iter()
.map(|p| ListRow {
label: p.label.clone(),
selected: false,
on_click: Msg::FetchFrom(p.addr.clone()),
})
.collect();
let caption = if model.peers.is_empty() {
"pares en la LAN: ninguno aún".to_string()
} else {
format!("pares en la LAN · {} (click para jalar)", model.peers.len())
};
let spec = ListSpec {
total: rows.len(),
rows,
caption: Some(caption),
truncated_hint: None,
row_height: ROW_H,
palette: *palette,
};
let list_wrap = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
flex_grow: 1.0,
flex_basis: length(0.0_f32),
min_size: Size {
width: length(0.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(vec![list_view(spec)]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: length(LIST_WIDTH),
height: percent(1.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.children(vec![addr_row, cancel_row, list_wrap])
}
/// Coincidencia sobre título, cuerpo y etiquetas. Case-insensitive.
pub(crate) fn note_matches(n: &Note, query: &str) -> bool {
if n.matches(query) {
return true;
}
let q = query.to_lowercase();
n.tags.iter().any(|t| t.to_lowercase().contains(&q))
}
pub(crate) fn row_label(n: &Note, mass: f32) -> String {
let title = if n.title.is_empty() {
"(sin título)"
} else {
n.title.as_str()
};
// Una barra de tres bloques visualiza la masa (0..1.5 mapeada a
// 0..3). Sobre el horizonte se ve llena; cayendo, se vacía.
let bars = (mass.clamp(0.0, 1.5) / 0.5).round() as usize;
let glyph: String = (0..3)
.map(|i| if i < bars { '▮' } else { '▯' })
.collect();
format!("{glyph} {title}")
}
pub(crate) fn editor_panel(
model: &Model,
input_palette: &TextInputPalette,
editor_palette: &EditorPalette,
) -> View<Msg> {
let none_view = || -> 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(model.theme.bg_panel)
.text_aligned(
"selecciona o crea una nota".to_string(),
12.0,
model.theme.fg_muted,
Alignment::Center,
)
};
if model.selected.is_none() {
return wrap_panel(model, none_view());
}
let metrics = EditorMetrics::for_font_size(13.0);
let title_field = field(
model,
"título",
text_input_view(
&model.title,
"(sin título)",
model.focus == Focus::Title,
input_palette,
Msg::Focus(Focus::Title),
),
);
let body_input = text_editor_view(
&model.body,
editor_palette,
metrics,
EDITOR_VISIBLE_LINES,
|ev| Some(Msg::EditorPointer(ev)),
);
let body_field = body_field_view(model, body_input);
let tags_field = field(
model,
"etiquetas (coma separadas)",
text_input_view(
&model.tags,
"p. ej. cocina, jardín",
model.focus == Focus::Tags,
input_palette,
Msg::Focus(Focus::Tags),
),
);
let stats = stats_view(model);
let column = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(10.0_f32),
bottom: length(10.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(8.0_f32),
},
..Default::default()
})
.fill(model.theme.bg_panel)
.children(vec![title_field, body_field, tags_field, stats]);
wrap_panel(model, column)
}
pub(crate) fn wrap_panel(_model: &Model, child: View<Msg>) -> View<Msg> {
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),
},
..Default::default()
})
.children(vec![child])
}
pub(crate) fn field(model: &Model, label: &str, control: View<Msg>) -> View<Msg> {
let label_node = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(14.0_f32),
},
..Default::default()
})
.text_aligned(
label.to_string(),
FIELD_LABEL_SIZE,
model.theme.fg_muted,
Alignment::Start,
);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
flex_shrink: 0.0,
gap: Size {
width: length(0.0_f32),
height: length(2.0_f32),
},
..Default::default()
})
.children(vec![label_node, control])
}
pub(crate) fn body_field_view(model: &Model, editor: View<Msg>) -> View<Msg> {
let label_node = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(14.0_f32),
},
..Default::default()
})
.text_aligned(
"cuerpo (wiki-links con [[Título]])".to_string(),
FIELD_LABEL_SIZE,
model.theme.fg_muted,
Alignment::Start,
);
let focused = model.focus == Focus::Body;
let border = if focused {
model.theme.border_focus
} else {
model.theme.border
};
let editor_wrap = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
flex_grow: 1.0,
padding: Rect {
left: length(1.0_f32),
right: length(1.0_f32),
top: length(1.0_f32),
bottom: length(1.0_f32),
},
..Default::default()
})
.fill(border)
.radius(4.0)
.on_click(Msg::Focus(Focus::Body))
.children(vec![editor]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
flex_grow: 1.0,
min_size: Size {
width: length(0.0_f32),
height: length(0.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(2.0_f32),
},
..Default::default()
})
.children(vec![label_node, editor_wrap])
}
pub(crate) fn stats_view(model: &Model) -> View<Msg> {
let Some(id) = model.selected else {
return View::new(Style::default());
};
let fwd = model.store.forward_links(id);
let back = model.store.backlinks(id);
let fwd_titles: Vec<String> = fwd
.iter()
.filter_map(|i| model.store.get(*i).map(|n| n.title.clone()))
.collect();
let back_titles: Vec<String> = back
.iter()
.filter_map(|i| model.store.get(*i).map(|n| n.title.clone()))
.collect();
let nearest: Vec<String> = model
.field
.nearest(id, 3)
.into_iter()
.filter_map(|(nid, score)| {
model
.store
.get(nid)
.map(|n| format!("{} ({:.2})", n.title, score))
})
.collect();
let mut lines = vec![
format!("→ enlaza a: {}", join_or_dash(&fwd_titles)),
format!("← backlinks: {}", join_or_dash(&back_titles)),
format!(" vecinos: {}", join_or_dash(&nearest)),
];
// Procedencia: si la nota llegó por compartir, lleva una etiqueta
// `de:<autor>`. La mostramos explícita.
if let Some(n) = model.store.get(id) {
let autores: Vec<&str> = n
.tags
.iter()
.filter_map(|t| t.strip_prefix("de:"))
.collect();
if !autores.is_empty() {
lines.push(format!("✎ de: {}", autores.join(", ")));
}
}
let nodes: Vec<View<Msg>> = lines
.into_iter()
.map(|s| {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(16.0_f32),
},
..Default::default()
})
.text_aligned(s, 11.0, model.theme.fg_muted, Alignment::Start)
})
.collect();
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
flex_shrink: 0.0,
..Default::default()
})
.children(nodes)
}
pub(crate) fn join_or_dash(items: &[String]) -> String {
if items.is_empty() {
"".to_string()
} else {
items.join(", ")
}
}
@@ -0,0 +1,22 @@
[package]
name = "khipu-brahman"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "khipu-brahman — transporte de sobres khipu sobre libp2p (BrahmanNet): stream cifrado /khipu/sobre/1.0.0 + descubrimiento por DHT. El sobre se verifica con khipu-share::open; el transporte no necesita ser confiable."
[dependencies]
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 }
thiserror = { workspace = true }
[dev-dependencies]
agora-core = { workspace = true }
+275
View File
@@ -0,0 +1,275 @@
//! `khipu-brahman` — 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`.
use std::sync::Arc;
use std::time::Duration;
use card_net::{BrahmanNet, Multiaddr, PeerId, Protocol};
use futures::StreamExt;
use khipu_share::SignedBundle;
use libp2p::StreamProtocol;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio_util::compat::FuturesAsyncReadCompatExt;
/// Protocolo de stream libp2p para transferir un sobre khipu.
pub const SOBRE_PROTOCOL: StreamProtocol = StreamProtocol::new("/khipu/sobre/1.0.0");
/// Clave DHT bajo la que los cuadernos khipu se anuncian y se descubren.
const KHIPU_DHT_KEY: &[u8] = b"khipu/sobre/1.0.0";
/// Tope defensivo de un sobre entrante (64 MiB), igual que el transporte LAN.
const MAX_SOBRE: u32 = 64 * 1024 * 1024;
/// Falla del transporte libp2p de sobres.
#[derive(Debug, thiserror::Error)]
pub enum BrahmanError {
#[error("nodo libp2p: {0}")]
Nodo(String),
#[error("abrir stream: {0}")]
Stream(String),
#[error("marco inválido: {0}")]
Marco(String),
#[error("io de red: {0}")]
Io(String),
#[error("sobre ilegible: {0}")]
Sobre(String),
}
/// Un cuaderno khipu en la red P2P. Envoltura fina sobre [`BrahmanNet`]
/// que habla el protocolo de sobres.
pub struct KhipuNode {
net: Arc<BrahmanNet>,
}
impl KhipuNode {
/// Crea un nodo con su propio [`BrahmanNet`] (keypair efímera). Debe
/// llamarse dentro de un runtime de tokio — el swarm corre en una task.
pub fn standalone() -> Result<Self, BrahmanError> {
let net = BrahmanNet::new().map_err(|e| BrahmanError::Nodo(e.to_string()))?;
Ok(Self { net: Arc::new(net) })
}
/// Comparte un [`BrahmanNet`] ya existente (p. ej. el mismo nodo que
/// usan agora/minga), registrando el protocolo de sobres encima.
pub fn sharing(net: Arc<BrahmanNet>) -> Self {
Self { net }
}
/// Identidad de red de este nodo.
pub fn peer_id(&self) -> PeerId {
self.net.peer_id
}
/// Empieza a escuchar en `addr`; devuelve la dirección efectiva.
pub async fn listen(&self, addr: Multiaddr) -> Multiaddr {
self.net.listen(addr).await
}
/// Escucha en una multiaddr dada como texto y devuelve la **dirección
/// para compartir** ya con `/p2p/<peer-id>` — lista para pegarle a un
/// par. Pensada para callers que no quieren tocar tipos de libp2p.
pub async fn listen_str(&self, addr: &str) -> Result<String, BrahmanError> {
let m: Multiaddr = addr
.parse()
.map_err(|e| BrahmanError::Nodo(format!("multiaddr inválida: {e}")))?;
let bound = self.net.listen(m).await;
let s = bound.to_string();
// Una reserva de circuito ya viene con `…/p2p-circuit/p2p/<self>`:
// no le agregamos otro `/p2p/`. Un tcp pelado no trae peer-id, así
// que sí se lo anexamos para que sea una dirección de marcado.
if s.contains("/p2p/") {
Ok(s)
} else {
Ok(format!("{s}/p2p/{}", self.net.peer_id))
}
}
/// Marca a un par para conectarse.
pub fn dial(&self, addr: Multiaddr) {
self.net.dial(addr);
}
/// Marca a un par dado como texto (multiaddr).
pub fn dial_str(&self, addr: &str) -> Result<(), BrahmanError> {
let m: Multiaddr = addr
.parse()
.map_err(|e| BrahmanError::Nodo(format!("multiaddr inválida: {e}")))?;
self.net.dial(m);
Ok(())
}
/// Siembra la tabla DHT con un par conocido (bootstrap para descubrir).
pub fn add_peer(&self, peer: PeerId, addr: Multiaddr) {
self.net.add_dht_peer(peer, addr);
}
/// Anuncia que este nodo sirve un cuaderno khipu (clave DHT fija).
pub fn anunciar(&self) {
self.net.start_providing(KHIPU_DHT_KEY);
}
/// Descubre nodos khipu por DHT (sin incluirse a sí mismo).
pub async fn descubrir(&self) -> Vec<PeerId> {
let mut peers = self.net.find_providers(KHIPU_DHT_KEY).await;
let me = self.net.peer_id;
peers.retain(|p| *p != me);
peers
}
/// Sirve sobres: por cada stream entrante llama a `supply` y manda lo
/// que devuelva (típicamente leer `compartido.khipu`). `None` ⇒ no hay
/// sobre, cierra el stream. Bloqueante: corre en su propia task hasta
/// el shutdown del nodo.
pub fn run_serve<F>(&self, supply: F) -> tokio::task::JoinHandle<()>
where
F: Fn() -> Option<Vec<u8>> + Send + Sync + 'static,
{
let mut control = self.net.control.clone();
tokio::spawn(async move {
let mut incoming = match control.accept(SOBRE_PROTOCOL) {
Ok(i) => i,
Err(_) => return, // ya hay un accept para este protocolo
};
let supply = Arc::new(supply);
while let Some((_peer, stream)) = incoming.next().await {
let supply = Arc::clone(&supply);
tokio::spawn(async move {
if let Some(bytes) = supply() {
let mut compat = stream.compat();
let _ = write_frame(&mut compat, &bytes).await;
}
});
}
})
}
/// Jala el sobre de un par dado por su **dirección de marcado** como
/// texto (`/ip4/.../tcp/.../p2p/<peer-id>`): extrae el peer-id, dial-ea
/// y reintenta el fetch unos segundos mientras se establece la
/// conexión. La forma que usa la app.
pub async fn fetch_addr_str(&self, addr: &str) -> Result<SignedBundle, BrahmanError> {
let m: Multiaddr = addr
.parse()
.map_err(|e| BrahmanError::Nodo(format!("multiaddr inválida: {e}")))?;
let peer = peer_from_multiaddr(&m)
.ok_or_else(|| BrahmanError::Nodo("la multiaddr no trae /p2p/<peer-id>".into()))?;
self.net.dial(m);
let mut intentos = 0u32;
loop {
match self.fetch(peer).await {
Ok(s) => return Ok(s),
Err(_) if intentos < 60 => {
intentos += 1;
tokio::time::sleep(Duration::from_millis(100)).await;
}
Err(e) => return Err(e),
}
}
}
/// Jala el sobre de un par dado por su **peer-id** como texto (la forma
/// que devuelve [`descubrir`](Self::descubrir)). Reintenta mientras el
/// swarm establece la conexión usando las direcciones que aprendió por
/// la DHT/identify. La app la usa para jalar de un par descubierto.
pub async fn fetch_peer_str(&self, peer: &str) -> Result<SignedBundle, BrahmanError> {
let pid: PeerId = peer
.parse()
.map_err(|e| BrahmanError::Nodo(format!("peer-id inválido: {e}")))?;
let mut intentos = 0u32;
loop {
match self.fetch(pid).await {
Ok(s) => return Ok(s),
Err(_) if intentos < 60 => {
intentos += 1;
tokio::time::sleep(Duration::from_millis(100)).await;
}
Err(e) => return Err(e),
}
}
}
/// Abre un stream a `peer` y jala su sobre. **No lo verifica** — el
/// caller debe pasarlo por [`khipu_share::open`] antes de confiar.
pub async fn fetch(&self, peer: PeerId) -> Result<SignedBundle, BrahmanError> {
let mut control = self.net.control.clone();
let stream = control
.open_stream(peer, SOBRE_PROTOCOL)
.await
.map_err(|e| BrahmanError::Stream(e.to_string()))?;
let mut compat = stream.compat();
let bytes = read_frame(&mut compat).await?;
SignedBundle::from_bytes(&bytes).map_err(|e| BrahmanError::Sobre(format!("{e:?}")))
}
}
/// Extrae el `PeerId` **destino** de una multiaddr: el ÚLTIMO componente
/// `/p2p/<id>`. En una directa (`…/tcp/P/p2p/<peer>`) es el único; en un
/// circuito (`…/p2p/<relay>/p2p-circuit/p2p/<destino>`) es el de después
/// del relay, no el relay.
fn peer_from_multiaddr(addr: &Multiaddr) -> Option<PeerId> {
addr.iter()
.filter_map(|p| match p {
Protocol::P2p(id) => Some(id),
_ => None,
})
.last()
}
async fn write_frame<S>(stream: &mut S, payload: &[u8]) -> Result<(), BrahmanError>
where
S: AsyncWrite + Unpin,
{
if payload.len() as u64 > MAX_SOBRE as u64 {
return Err(BrahmanError::Marco("sobre demasiado grande".into()));
}
stream
.write_all(&(payload.len() as u32).to_be_bytes())
.await
.map_err(|e| BrahmanError::Io(e.to_string()))?;
stream
.write_all(payload)
.await
.map_err(|e| BrahmanError::Io(e.to_string()))?;
stream.flush().await.map_err(|e| BrahmanError::Io(e.to_string()))?;
Ok(())
}
async fn read_frame<S>(stream: &mut S) -> Result<Vec<u8>, BrahmanError>
where
S: AsyncRead + Unpin,
{
let mut len_buf = [0u8; 4];
stream
.read_exact(&mut len_buf)
.await
.map_err(|e| BrahmanError::Io(e.to_string()))?;
let len = u32::from_be_bytes(len_buf);
if len > MAX_SOBRE {
return Err(BrahmanError::Marco(format!("largo {len} excede el tope")));
}
let mut buf = vec![0u8; len as usize];
stream
.read_exact(&mut buf)
.await
.map_err(|e| BrahmanError::Io(e.to_string()))?;
Ok(buf)
}
@@ -0,0 +1,193 @@
//! Integración: transferir un sobre khipu entre dos nodos libp2p reales en
//! localhost. Espeja el molde de `minga-p2p`/`agora-net-brahman`: un nodo
//! escucha y sirve, el otro dial-ea, abre stream y jala; el sobre que llega
//! se verifica con `khipu_share::open`.
use std::time::{Duration, Instant};
use agora_core::Keypair;
use card_net::Multiaddr;
use khipu_brahman::KhipuNode;
use khipu_share::{open, seal, SharedNote};
/// Camino que usa la app: `listen_str` da la dirección para compartir y
/// `fetch_addr_str` la consume (dial + reintento + fetch).
#[tokio::test]
async fn jalar_por_direccion_str_como_la_app() {
let autor = Keypair::from_seed([32u8; 32]);
let sobre = seal(
&autor,
vec![SharedNote {
title: "via str".into(),
body: "listen_str + fetch_addr_str".into(),
tags: vec![],
}],
1,
)
.unwrap();
let bytes = sobre.to_bytes().unwrap();
let server = KhipuNode::standalone().unwrap();
let client = KhipuNode::standalone().unwrap();
let dial = server.listen_str("/ip4/127.0.0.1/tcp/0").await.unwrap();
let _serve = server.run_serve(move || Some(bytes.clone()));
// fetch_addr_str ya reintenta internamente mientras se conecta.
let recibido = client.fetch_addr_str(&dial).await.expect("fetch por str");
let bundle = open(&recibido).expect("verifica tras el viaje");
assert_eq!(bundle.notes[0].title, "via str");
}
/// Descubrimiento por DHT: A publica y se anuncia bajo la clave khipu en
/// la DHT; B —unido a la malla por un rendezvous— la descubre con
/// `descubrir()` y le jala el cuaderno por peer-id, sin conocer su
/// dirección de antemano.
#[tokio::test]
async fn descubrir_por_dht_y_jalar() {
let autor = Keypair::from_seed([34u8; 32]);
let sobre = seal(
&autor,
vec![SharedNote {
title: "via dht".into(),
body: "descubierto por la DHT".into(),
tags: vec![],
}],
1,
)
.unwrap();
let bytes = sobre.to_bytes().unwrap();
// Rendezvous: nodo de la malla al que ambos se conectan.
let rendezvous = KhipuNode::standalone().unwrap();
let r_addr = rendezvous.listen_str("/ip4/127.0.0.1/tcp/0").await.unwrap();
// A publica, se une a la malla y se anuncia en la DHT.
let a = KhipuNode::standalone().unwrap();
let _a_listen = a.listen_str("/ip4/127.0.0.1/tcp/0").await.unwrap();
let _serve = a.run_serve(move || Some(bytes.clone()));
a.dial_str(&r_addr).unwrap();
tokio::time::sleep(Duration::from_secs(2)).await;
a.anunciar();
tokio::time::sleep(Duration::from_secs(1)).await;
// B se une por el rendezvous y descubre a A por DHT.
let b = KhipuNode::standalone().unwrap();
b.dial_str(&r_addr).unwrap();
tokio::time::sleep(Duration::from_secs(2)).await;
let mut peers = Vec::new();
for _ in 0..20 {
peers = b.descubrir().await;
if peers.contains(&a.peer_id()) {
break;
}
tokio::time::sleep(Duration::from_millis(300)).await;
}
assert!(peers.contains(&a.peer_id()), "B debe descubrir a A por DHT");
// B jala de A por su peer-id (la dirección la aprendió por la DHT).
let recibido = tokio::time::timeout(
Duration::from_secs(20),
b.fetch_peer_str(&a.peer_id().to_string()),
)
.await
.expect("el fetch por peer-id no debería colgar")
.expect("recibir por peer-id descubierto");
let bundle = open(&recibido).expect("verifica");
assert_eq!(bundle.notes[0].title, "via dht");
}
/// NAT traversal: A reserva un circuito en un relay público y sirve;
/// B le jala el cuaderno *a través del relay* (Circuit Relay v2), sin
/// dirección directa a A. Verifica la maquinaria relay/dcutr de card-net.
#[tokio::test]
async fn jalar_a_traves_de_un_relay() {
let autor = Keypair::from_seed([33u8; 32]);
let sobre = seal(
&autor,
vec![SharedNote {
title: "relay".into(),
body: "viajó por un circuito relay".into(),
tags: vec![],
}],
1,
)
.unwrap();
let bytes = sobre.to_bytes().unwrap();
// Relay público.
let relay = KhipuNode::standalone().unwrap();
let relay_addr = relay.listen_str("/ip4/127.0.0.1/tcp/0").await.unwrap();
// A: sirve y reserva un circuito en el relay (su dirección pasa a ser
// `…/p2p/<relay>/p2p-circuit/p2p/<A>`).
let a = KhipuNode::standalone().unwrap();
let _serve = a.run_serve(move || Some(bytes.clone()));
// A se conecta al relay; AutoNAT (con el dial-back de A) le confirma al
// relay su dirección externa, necesaria para la reserva. Esperamos a
// que ese sondeo (boot_delay + round-trip) ocurra antes de reservar.
a.dial_str(&relay_addr).unwrap();
tokio::time::sleep(Duration::from_secs(6)).await;
let circuit = format!("{relay_addr}/p2p-circuit");
let a_addr = tokio::time::timeout(Duration::from_secs(15), a.listen_str(&circuit))
.await
.expect("la reserva del circuito no debería colgar")
.unwrap();
assert!(a_addr.contains("p2p-circuit"), "A debe anunciarse vía circuito");
// B jala el cuaderno de A a través del relay.
let b = KhipuNode::standalone().unwrap();
let recibido = tokio::time::timeout(Duration::from_secs(25), b.fetch_addr_str(&a_addr))
.await
.expect("el fetch por relay no debería colgar")
.expect("recibir vía relay");
let bundle = open(&recibido).expect("verifica tras el viaje por relay");
assert_eq!(bundle.notes[0].title, "relay");
}
#[tokio::test]
async fn jalar_un_sobre_entre_dos_nodos_libp2p() {
// Sellar el cuaderno a servir.
let autor = Keypair::from_seed([31u8; 32]);
let sobre = seal(
&autor,
vec![SharedNote {
title: "P2P".into(),
body: "viajó por libp2p".into(),
tags: vec!["brahman".into()],
}],
1,
)
.unwrap();
let bytes = sobre.to_bytes().unwrap();
// Dos nodos en localhost.
let server = KhipuNode::standalone().unwrap();
let client = KhipuNode::standalone().unwrap();
let server_pid = server.peer_id();
let addr = server.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await;
let _serve = server.run_serve(move || Some(bytes.clone()));
// El cliente dial-ea por multiaddr + peer-id.
let dial: Multiaddr = format!("{addr}/p2p/{server_pid}").parse().unwrap();
client.dial(dial);
// Reintentar el fetch hasta que la conexión esté lista.
let deadline = Instant::now() + Duration::from_secs(8);
let recibido = loop {
match client.fetch(server_pid).await {
Ok(s) => break s,
Err(_) if Instant::now() < deadline => {
tokio::time::sleep(Duration::from_millis(100)).await;
}
Err(e) => panic!("fetch falló: {e}"),
}
};
// Idéntico bit a bit y verificable.
assert_eq!(recibido, sobre);
let bundle = open(&recibido).expect("firma válida tras el viaje libp2p");
assert_eq!(bundle.notes[0].title, "P2P");
assert_eq!(bundle.author, autor.public_key());
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "khipu-core"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "khipu-app — núcleo de toma de notas: el modelo Note, el almacén con etiquetas y búsqueda, y el grafo de wiki-links [[...]] con backlinks."
[dependencies]
serde = { workspace = true }
+21
View File
@@ -0,0 +1,21 @@
# khipu-core
> Modelo de nota + store para [khipu](../README.md). Sin UI.
`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).
## API
```rust
use khipu_core::{Note, Store};
let store = Store::open()?;
let id = store.create("nota nueva")?;
let note = store.read(id)?;
store.touch(id)?; // refresca last_access
```
## Deps
- `serde`, `serde_json`
- `directories` para el XDG path
+21
View File
@@ -0,0 +1,21 @@
# khipu-core
> Note model + store for [khipu](../README.md). No UI.
`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).
## API
```rust
use khipu_core::{Note, Store};
let store = Store::open()?;
let id = store.create("new note")?;
let note = store.read(id)?;
store.touch(id)?; // refresh last_access
```
## Deps
- `serde`, `serde_json`
- `directories` for XDG path
+23
View File
@@ -0,0 +1,23 @@
//! `khipu_app-core` — el núcleo agnóstico de la toma de notas.
//!
//! 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.
//!
//! - [`note`] — el modelo [`Note`].
//! - [`links`] — el parser de wiki-links `[[...]]`.
//! - [`store`] — el [`NoteStore`] y el grafo de enlaces.
//!
//! La gravedad semántica (clustering por afinidad de embeddings) vive en
//! `khipu_app-gravity`; las lentes visuales, en los crates de frontend.
#![forbid(unsafe_code)]
pub mod links;
pub mod note;
pub mod store;
pub use links::parse_links;
pub use note::{Note, NoteId};
pub use store::NoteStore;
+93
View File
@@ -0,0 +1,93 @@
//! Parser de wiki-links — los destinos `[[...]]` dentro de una nota.
//!
//! Un solo format: dobles corchetes con el título adentro. El texto se
//! recorta; los enlaces vacíos se descartan; el orden de aparición se
//! conserva y se deduplica (un mismo destino enlazado dos veces cuenta
//! una sola vez como arista).
/// Extrae los destinos `[[...]]` de `text`, recortados y deduplicados,
/// en orden de aparición.
pub fn parse_links(text: &str) -> Vec<String> {
let bytes = text.as_bytes();
let mut out: Vec<String> = Vec::new();
let mut i = 0;
while i + 3 < bytes.len() {
if bytes[i] == b'[' && bytes[i + 1] == b'[' {
if let Some(close) = find_close(text, i + 2) {
let inner = text[i + 2..close].trim();
if !inner.is_empty() && !out.iter().any(|l| l == inner) {
out.push(inner.to_string());
}
i = close + 2;
continue;
}
}
// Avanza un carácter UTF-8 completo, no un byte.
i += utf8_len(bytes[i]);
}
out
}
/// Posición del `]]` que cierra a partir de `from`, si existe.
fn find_close(text: &str, from: usize) -> Option<usize> {
let bytes = text.as_bytes();
let mut i = from;
while i + 1 < bytes.len() {
if bytes[i] == b']' && bytes[i + 1] == b']' {
return Some(i);
}
// Un `[[` antes del cierre aborta: enlaces anidados no son válidos.
if bytes[i] == b'[' && bytes[i + 1] == b'[' {
return None;
}
i += 1;
}
None
}
/// Largo en bytes del carácter UTF-8 que empieza en `b`.
fn utf8_len(b: u8) -> usize {
match b {
0x00..=0x7F => 1,
0xC0..=0xDF => 2,
0xE0..=0xEF => 3,
_ => 4,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_simple_links() {
let links = parse_links("ver [[Cocina]] y también [[Jardín]].");
assert_eq!(links, vec!["Cocina", "Jardín"]);
}
#[test]
fn trims_inner_whitespace() {
assert_eq!(parse_links("[[ Taller ]]"), vec!["Taller"]);
}
#[test]
fn empty_links_are_dropped() {
assert_eq!(parse_links("[[]] y [[ ]]"), Vec::<String>::new());
}
#[test]
fn duplicates_collapse_to_one() {
assert_eq!(parse_links("[[A]] [[B]] [[A]]"), vec!["A", "B"]);
}
#[test]
fn unclosed_bracket_is_ignored() {
assert_eq!(parse_links("texto [[sin cerrar"), Vec::<String>::new());
}
#[test]
fn handles_unicode_content_around_links() {
let links = parse_links("café ☕ con [[Niños]] — añoño");
assert_eq!(links, vec!["Niños"]);
}
}
+108
View File
@@ -0,0 +1,108 @@
//! El modelo `Note` — la unidad de khipu_app.
use serde::{Deserialize, Serialize};
use crate::links::parse_links;
/// Identificador de una nota. 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.
///
/// `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`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Note {
pub id: NoteId,
pub title: String,
pub body: String,
pub tags: Vec<String>,
/// Segundo Unix de creación.
pub created_at: u64,
/// Segundo Unix de la última edición.
pub updated_at: u64,
/// Segundo Unix del último acceso (lectura). Defecto: `created_at`
/// en notas nuevas; en payloads viejos sin el campo, `0` y el
/// 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.
#[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.
#[serde(default)]
pub pos: Option<(f32, f32)>,
}
fn default_mass() -> f32 {
1.0
}
impl Note {
/// Destinos `[[...]]` que el cuerpo de la nota referencia.
pub fn outgoing_links(&self) -> Vec<String> {
parse_links(&self.body)
}
/// `true` si la nota lleva la etiqueta `tag` (sin distinguir mayúsculas).
pub fn has_tag(&self, tag: &str) -> bool {
self.tags.iter().any(|t| t.eq_ignore_ascii_case(tag))
}
/// `true` si `query` aparece en el título o el cuerpo (sin distinguir
/// mayúsculas).
pub fn matches(&self, query: &str) -> bool {
let q = query.to_lowercase();
self.title.to_lowercase().contains(&q) || self.body.to_lowercase().contains(&q)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn note(title: &str, body: &str) -> Note {
Note {
id: 1,
title: title.into(),
body: body.into(),
tags: vec!["casa".into()],
created_at: 0,
updated_at: 0,
last_access: 0,
mass: 1.0,
pos: None,
}
}
#[test]
fn outgoing_links_reads_the_body() {
let n = note("Cocina", "preparar con [[Horno]] y [[Cuchillos]]");
assert_eq!(n.outgoing_links(), vec!["Horno", "Cuchillos"]);
}
#[test]
fn has_tag_is_case_insensitive() {
let n = note("x", "y");
assert!(n.has_tag("CASA"));
assert!(!n.has_tag("trabajo"));
}
#[test]
fn matches_searches_title_and_body() {
let n = note("Lista de mercado", "comprar pan");
assert!(n.matches("MERCADO"));
assert!(n.matches("pan"));
assert!(!n.matches("ausente"));
}
}
+348
View File
@@ -0,0 +1,348 @@
//! `NoteStore` — el almacén de notas y su grafo de enlaces.
//!
//! Guarda las notas en un `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.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::note::{Note, NoteId};
/// El almacén de notas de khipu_app.
#[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,
}
impl NoteStore {
pub fn new() -> Self {
Self { notes: BTreeMap::new(), next_id: 1 }
}
/// Crea una nota y devuelve su id. Empieza con `mass = 1.0` y
/// `last_access = now` — recién nacida, plenamente visible.
pub fn create(
&mut self,
title: impl Into<String>,
body: impl Into<String>,
tags: Vec<String>,
now: u64,
) -> NoteId {
let id = self.next_id;
self.next_id += 1;
self.notes.insert(
id,
Note {
id,
title: title.into(),
body: body.into(),
tags,
created_at: now,
updated_at: now,
last_access: now,
mass: 1.0,
pos: None,
},
);
id
}
pub fn len(&self) -> usize {
self.notes.len()
}
pub fn is_empty(&self) -> bool {
self.notes.is_empty()
}
pub fn get(&self, id: NoteId) -> Option<&Note> {
self.notes.get(&id)
}
pub fn get_mut(&mut self, id: NoteId) -> Option<&mut Note> {
self.notes.get_mut(&id)
}
/// Itera las notas en orden de id (determinista).
pub fn iter(&self) -> impl Iterator<Item = &Note> {
self.notes.values()
}
/// Reemplaza el cuerpo de una nota y actualiza su marca de tiempo.
/// También marca `last_access` — editar cuenta como acceso.
/// `false` si la nota no existe.
pub fn update_body(&mut self, id: NoteId, body: impl Into<String>, now: u64) -> bool {
match self.notes.get_mut(&id) {
Some(n) => {
n.body = body.into();
n.updated_at = now;
n.last_access = now;
true
}
None => false,
}
}
/// 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ó.
pub fn touch(&mut self, id: NoteId, now: u64) -> bool {
match self.notes.get_mut(&id) {
Some(n) => {
n.last_access = now;
true
}
None => false,
}
}
/// Asigna directamente la masa de una nota. La física vive en
/// `khipu-gravity`; el store sólo persiste el resultado.
pub fn set_mass(&mut self, id: NoteId, mass: f32) -> bool {
match self.notes.get_mut(&id) {
Some(n) => {
n.mass = mass;
true
}
None => false,
}
}
/// 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
}
None => false,
}
}
/// Elimina una nota y la devuelve.
pub fn remove(&mut self, id: NoteId) -> Option<Note> {
self.notes.remove(&id)
}
/// 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()
}
/// Notas cuyo título o cuerpo contienen `query`, en orden de id.
pub fn search(&self, query: &str) -> Vec<&Note> {
self.notes.values().filter(|n| n.matches(query)).collect()
}
/// Ids de las notas cuyo título es `title` (sin distinguir
/// mayúsculas). Pueden ser varias: los títulos no son únicos.
pub fn resolve_title(&self, title: &str) -> Vec<NoteId> {
self.notes
.values()
.filter(|n| n.title.eq_ignore_ascii_case(title))
.map(|n| n.id)
.collect()
}
/// Ids de las notas a las que `id` enlaza por `[[...]]`, resueltas y
/// deduplicadas. Un enlace a un título inexistente simplemente no
/// aporta ningún id (enlace colgante).
pub fn forward_links(&self, id: NoteId) -> Vec<NoteId> {
let Some(note) = self.notes.get(&id) else {
return Vec::new();
};
let mut out: Vec<NoteId> = Vec::new();
for title in note.outgoing_links() {
for target in self.resolve_title(&title) {
if !out.contains(&target) {
out.push(target);
}
}
}
out.sort_unstable();
out
}
/// Ids de las notas que enlazan hacia `id` (backlinks), en orden de id.
pub fn backlinks(&self, id: NoteId) -> Vec<NoteId> {
let Some(target) = self.notes.get(&id) else {
return Vec::new();
};
let title = &target.title;
self.notes
.values()
.filter(|n| {
n.id != id
&& n.outgoing_links()
.iter()
.any(|l| l.eq_ignore_ascii_case(title))
})
.map(|n| n.id)
.collect()
}
/// Notas sin ningún backlink — las islas del grafo.
pub fn orphans(&self) -> Vec<&Note> {
self.notes
.values()
.filter(|n| self.backlinks(n.id).is_empty())
.collect()
}
/// Destinos `[[...]]` que ninguna nota satisface — enlaces colgantes.
pub fn dangling_links(&self) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for note in self.notes.values() {
for title in note.outgoing_links() {
if self.resolve_title(&title).is_empty()
&& !out.iter().any(|t| t.eq_ignore_ascii_case(&title))
{
out.push(title);
}
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Almacén con tres notas enlazadas: Índice → Cocina, Índice → Jardín.
fn seeded() -> (NoteStore, NoteId, NoteId, NoteId) {
let mut s = NoteStore::new();
let indice = s.create("Índice", "ver [[Cocina]] y [[Jardín]]", vec!["meta".into()], 100);
let cocina = s.create("Cocina", "recetas; vuelve al [[Índice]]", vec!["casa".into()], 100);
let jardin = s.create("Jardín", "plantas y riego", vec!["casa".into()], 100);
(s, indice, cocina, jardin)
}
#[test]
fn create_assigns_monotonic_ids() {
let (s, indice, cocina, jardin) = seeded();
assert_eq!((indice, cocina, jardin), (1, 2, 3));
assert_eq!(s.len(), 3);
}
#[test]
fn forward_links_resolve_by_title() {
let (s, indice, cocina, jardin) = seeded();
assert_eq!(s.forward_links(indice), vec![cocina, jardin]);
}
#[test]
fn backlinks_find_incoming_references() {
let (s, indice, cocina, _) = seeded();
// Cocina enlaza al Índice → el Índice tiene a Cocina como backlink.
assert_eq!(s.backlinks(indice), vec![cocina]);
}
#[test]
fn link_resolution_is_case_insensitive() {
let mut s = NoteStore::new();
let a = s.create("Taller", "trabajo", vec![], 0);
let b = s.create("Notas", "voy al [[taller]]", vec![], 0);
assert_eq!(s.forward_links(b), vec![a]);
assert_eq!(s.backlinks(a), vec![b]);
}
#[test]
fn by_tag_filters() {
let (s, _, cocina, jardin) = seeded();
let casa: Vec<_> = s.by_tag("casa").iter().map(|n| n.id).collect();
assert_eq!(casa, vec![cocina, jardin]);
}
#[test]
fn search_scans_title_and_body() {
let (s, _, cocina, _) = seeded();
let hits: Vec<_> = s.search("recetas").iter().map(|n| n.id).collect();
assert_eq!(hits, vec![cocina]);
}
#[test]
fn update_body_changes_links_and_timestamp() {
let (mut s, indice, _, jardin) = seeded();
assert!(s.update_body(indice, "ahora sólo [[Jardín]]", 200));
assert_eq!(s.forward_links(indice), vec![jardin]);
assert_eq!(s.get(indice).unwrap().updated_at, 200);
}
#[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();
assert!(orphan_ids.contains(&aislada));
assert!(!orphan_ids.contains(&jardin));
}
#[test]
fn dangling_links_report_missing_targets() {
let mut s = NoteStore::new();
s.create("Nota", "apunta a [[Inexistente]]", vec![], 0);
assert_eq!(s.dangling_links(), vec!["Inexistente"]);
}
#[test]
fn create_initializes_mass_and_last_access() {
let mut s = NoteStore::new();
let id = s.create("x", "y", vec![], 1_700_000_000);
let n = s.get(id).unwrap();
assert_eq!(n.mass, 1.0);
assert_eq!(n.last_access, 1_700_000_000);
}
#[test]
fn touch_refreshes_last_access() {
let mut s = NoteStore::new();
let id = s.create("x", "y", vec![], 100);
assert!(s.touch(id, 500));
assert_eq!(s.get(id).unwrap().last_access, 500);
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();
assert!(s.remove(indice).is_some());
assert_eq!(s.len(), 2);
assert!(s.get(indice).is_none());
}
}
@@ -0,0 +1,15 @@
[package]
name = "khipu-gravity"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "khipu — dos físicas puras: masa temporal (decay/refuerzo con vida media y horizonte) y gravedad semántica (afinidad coseno, vecinos, clústeres y layout 2D)."
[dependencies]
khipu-core = { path = "../khipu-core" }
serde = { workspace = true }
# `libm`: la masa decae con `exp` y queremos mantenernos compatibles
# con futuros consumidores `no_std` (kernel wawa, WASM bare).
libm = { workspace = true }
+20
View File
@@ -0,0 +1,20 @@
# khipu-gravity
> Algoritmo de masa/decay para [khipu](../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.
## API
```rust
use khipu_gravity::{Gravity, Params};
let g = Gravity::new(Params::default());
let new_mass = g.decay(mass, dt);
let new_mass = g.reinforce(mass, boost);
```
## Deps
- `libm` para `exp` (sin `std::math` cuando se compila a WASM)
- Cero deps de I/O o tiempo (el caller pasa `dt`)
+20
View File
@@ -0,0 +1,20 @@
# khipu-gravity
> Mass/decay algorithm for [khipu](../README.md).
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.
## API
```rust
use khipu_gravity::{Gravity, Params};
let g = Gravity::new(Params::default());
let new_mass = g.decay(mass, dt);
let new_mass = g.reinforce(mass, boost);
```
## Deps
- `libm` for `exp` (no `std::math` when compiled to WASM)
- Zero I/O or time deps (caller passes `dt`)
+479
View File
@@ -0,0 +1,479 @@
//! `khipu-gravity` — dos físicas, ambas 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.
//! 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.
//!
//! Las dos 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 serde::{Deserialize, Serialize};
/// Parámetros de la física temporal de una nota.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Params {
/// Vida media: el `dt` (en segundos) tras el cual una masa se
/// reduce a la mitad sin acceso. Default 7 días.
pub half_life_secs: f32,
/// Incremento de masa por acceso. Default 0.4.
pub boost: f32,
/// Bajo este valor la nota cae al archivo (no se borra).
/// Default 0.10.
pub horizon: f32,
}
impl Default for Params {
fn default() -> Self {
Self {
half_life_secs: 7.0 * 24.0 * 3600.0,
boost: 0.4,
horizon: 0.10,
}
}
}
/// Física temporal: decay con el tiempo, refuerzo con cada acceso.
/// Pura — no toca reloj ni store. El caller pasa `dt` y aplica el
/// resultado donde corresponda.
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct Gravity {
pub params: Params,
}
impl Gravity {
pub fn new(params: Params) -> Self {
Self { params }
}
/// Masa decaída tras `dt` segundos sin acceso. `dt` negativo o
/// `half_life_secs <= 0` devuelven la masa intacta.
pub fn decay(&self, mass: f32, dt_secs: f32) -> f32 {
if dt_secs <= 0.0 || self.params.half_life_secs <= 0.0 {
return mass.max(0.0);
}
let k = core::f32::consts::LN_2 / self.params.half_life_secs;
let factor = libm::expf(-k * dt_secs);
(mass * factor).max(0.0)
}
/// Masa tras un acceso — suma el `boost` configurado.
pub fn reinforce(&self, mass: f32) -> f32 {
(mass + self.params.boost).max(0.0)
}
/// `true` si la masa está sobre el horizonte (visible). `false`
/// significa archivo, no borrada.
pub fn is_visible(&self, mass: f32) -> bool {
mass >= self.params.horizon
}
}
/// Una nube de notas con su vector semántico — el dominio de la gravedad.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SemanticField {
/// `(id, vector)` en orden de inserción.
entries: Vec<(NoteId, Vec<f32>)>,
}
/// Posición 2D resultante de una nota tras el layout por gravedad.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct NotePlacement {
pub id: NoteId,
pub x: f32,
pub y: f32,
}
/// Parámetros del layout dirigido por fuerzas.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct GravityConfig {
/// Pasos de relajación.
pub iterations: usize,
/// Fuerza de atracción entre notas afines.
pub attraction: f32,
/// Fuerza de repulsión entre todo par de notas.
pub repulsion: f32,
/// Radio del círculo de posiciones iniciales.
pub radius: f32,
/// Fracción de la fuerza neta que se aplica por paso (amortiguación).
pub step: f32,
}
impl Default for GravityConfig {
fn default() -> Self {
Self {
iterations: 120,
attraction: 0.02,
repulsion: 800.0,
radius: 240.0,
step: 0.85,
}
}
}
/// Similitud coseno de dos vectores. `None` si difieren de largo.
fn cosine(a: &[f32], b: &[f32]) -> Option<f32> {
if a.len() != b.len() {
return None;
}
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
if na == 0.0 || nb == 0.0 {
return Some(0.0);
}
Some((dot / (na * nb)).clamp(-1.0, 1.0))
}
impl SemanticField {
pub fn new() -> Self {
Self::default()
}
/// Cantidad de notas en el campo.
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Inserta o reemplaza el vector de una nota.
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;
} else {
self.entries.push((id, vector));
}
}
/// 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
}
/// Itera `(id, &vector)` en orden de inserción. Útil para serializar
/// el campo completo (persistencia, exportar).
pub fn iter(&self) -> impl Iterator<Item = (NoteId, &[f32])> {
self.entries.iter().map(|(id, v)| (*id, v.as_slice()))
}
fn vector_of(&self, id: NoteId) -> Option<&[f32]> {
self.entries
.iter()
.find(|(eid, _)| *eid == id)
.map(|(_, v)| v.as_slice())
}
/// Afinidad (similitud coseno) entre dos notas. `None` si alguna no
/// existe o los vectores difieren de largo.
pub fn affinity(&self, a: NoteId, b: NoteId) -> Option<f32> {
cosine(self.vector_of(a)?, self.vector_of(b)?)
}
/// Las `k` notas más afines a `id`, de mayor a menor afinidad.
/// Empata por id ascendente para que el orden sea determinista.
pub fn nearest(&self, id: NoteId, k: usize) -> Vec<(NoteId, f32)> {
let Some(base) = self.vector_of(id) 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)))
.collect();
scored.sort_by(|a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(core::cmp::Ordering::Equal)
.then(a.0.cmp(&b.0))
});
scored.truncate(k);
scored
}
/// 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();
let mut parent: Vec<usize> = (0..n).collect();
fn find(parent: &mut [usize], i: usize) -> usize {
let mut root = i;
while parent[root] != root {
root = parent[root];
}
let mut cur = i;
while parent[cur] != root {
let next = parent[cur];
parent[cur] = root;
cur = next;
}
root
}
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);
if sim >= threshold {
let (ri, rj) = (find(&mut parent, i), find(&mut parent, j));
if ri != rj {
parent[ri] = rj;
}
}
}
}
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);
}
let mut out: Vec<Vec<NoteId>> = groups.into_values().collect();
for c in &mut out {
c.sort_unstable();
}
out.sort_by(|a, b| a.first().cmp(&b.first()));
out
}
/// 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();
if n == 0 {
return Vec::new();
}
if n == 1 {
return vec![NotePlacement { id: self.entries[0].0, x: 0.0, y: 0.0 }];
}
// Posiciones iniciales repartidas en un círculo.
let mut pos: Vec<(f32, f32)> = (0..n)
.map(|i| {
let a = core::f32::consts::TAU * i as f32 / n as f32;
(cfg.radius * a.cos(), cfg.radius * a.sin())
})
.collect();
// Afinidades precomputadas (no cambian entre pasos).
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);
aff[i * n + j] = s;
aff[j * n + i] = s;
}
}
const EPS: f32 = 0.001;
for _ in 0..cfg.iterations {
let mut force = vec![(0.0f32, 0.0f32); n];
for i in 0..n {
for j in (i + 1)..n {
let dx = pos[j].0 - pos[i].0;
let dy = pos[j].1 - pos[i].1;
let dist = (dx * dx + dy * dy).sqrt().max(EPS);
let (ux, uy) = (dx / dist, dy / dist);
// Atracción crece con la distancia y la afinidad;
// repulsión cae con el cuadrado de la distancia.
let attract = cfg.attraction * aff[i * n + j] * dist;
let repel = cfg.repulsion / (dist * dist);
let net = attract - repel; // >0 → acercar
force[i].0 += net * ux;
force[i].1 += net * uy;
force[j].0 -= net * ux;
force[j].1 -= net * uy;
}
}
for i in 0..n {
pos[i].0 += force[i].0 * cfg.step;
pos[i].1 += force[i].1 * cfg.step;
}
}
self.entries
.iter()
.zip(pos)
.map(|((id, _), (x, y))| NotePlacement { id: *id, x, y })
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Tres vectores: 1 y 2 casi paralelos, 3 ortogonal.
fn field() -> SemanticField {
let mut f = SemanticField::new();
f.insert(1, vec![1.0, 0.0, 0.0]);
f.insert(2, vec![0.9, 0.1, 0.0]);
f.insert(3, vec![0.0, 0.0, 1.0]);
f
}
#[test]
fn affinity_is_high_for_aligned_vectors() {
let f = field();
let near = f.affinity(1, 2).unwrap();
let far = f.affinity(1, 3).unwrap();
assert!(near > 0.95);
assert!(far.abs() < 1e-6);
assert!(near > far);
}
#[test]
fn affinity_missing_note_is_none() {
assert!(field().affinity(1, 99).is_none());
}
#[test]
fn nearest_ranks_by_affinity() {
let f = field();
let near = f.nearest(1, 2);
assert_eq!(near[0].0, 2); // el más afín a 1
assert_eq!(near.len(), 2);
assert!(near[0].1 > near[1].1);
}
#[test]
fn insert_replaces_existing_vector() {
let mut f = SemanticField::new();
f.insert(1, vec![1.0, 0.0]);
f.insert(1, vec![0.0, 1.0]);
assert_eq!(f.len(), 1);
assert_eq!(f.vector_of(1), Some([0.0, 1.0].as_slice()));
}
#[test]
fn clusters_group_affine_notes() {
let f = field();
// Umbral alto: 1 y 2 juntos, 3 solo.
let cs = f.clusters(0.8);
assert_eq!(cs, vec![vec![1, 2], vec![3]]);
}
#[test]
fn low_threshold_merges_everything() {
let cs = field().clusters(-1.0);
assert_eq!(cs, vec![vec![1, 2, 3]]);
}
#[test]
fn gravity_layout_places_every_note() {
let placements = field().gravity_layout(&GravityConfig::default());
assert_eq!(placements.len(), 3);
let ids: Vec<_> = placements.iter().map(|p| p.id).collect();
assert_eq!(ids, vec![1, 2, 3]);
}
#[test]
fn gravity_pulls_affine_notes_closer() {
let f = field();
let p = f.gravity_layout(&GravityConfig::default());
let dist = |a: NoteId, b: NoteId| {
let pa = p.iter().find(|x| x.id == a).unwrap();
let pb = p.iter().find(|x| x.id == b).unwrap();
((pa.x - pb.x).powi(2) + (pa.y - pb.y).powi(2)).sqrt()
};
// Las notas afines (1,2) terminan más cerca que las disímiles (1,3).
assert!(dist(1, 2) < dist(1, 3));
}
#[test]
fn gravity_layout_is_deterministic() {
let f = field();
let a = f.gravity_layout(&GravityConfig::default());
let b = f.gravity_layout(&GravityConfig::default());
assert_eq!(a, b);
}
#[test]
fn remove_drops_a_note() {
let mut f = field();
assert!(f.remove(2));
assert_eq!(f.len(), 2);
assert!(!f.remove(99));
}
#[test]
fn iter_returns_vectors_in_insertion_order() {
let f = field();
let collected: Vec<_> = f.iter().map(|(id, v)| (id, v.to_vec())).collect();
assert_eq!(collected.len(), 3);
assert_eq!(collected[0].0, 1);
assert_eq!(collected[2].0, 3);
}
#[test]
fn empty_and_single_fields_are_handled() {
assert!(SemanticField::new().gravity_layout(&GravityConfig::default()).is_empty());
let mut one = SemanticField::new();
one.insert(7, vec![1.0, 1.0]);
let p = one.gravity_layout(&GravityConfig::default());
assert_eq!(p, vec![NotePlacement { id: 7, x: 0.0, y: 0.0 }]);
}
}
#[cfg(test)]
mod mass_tests {
use super::*;
#[test]
fn decay_halves_mass_after_one_half_life() {
let g = Gravity::new(Params {
half_life_secs: 1000.0,
..Params::default()
});
let after = g.decay(1.0, 1000.0);
assert!((after - 0.5).abs() < 1e-3);
}
#[test]
fn decay_with_zero_dt_is_a_no_op() {
let g = Gravity::default();
assert!((g.decay(0.7, 0.0) - 0.7).abs() < 1e-6);
}
#[test]
fn decay_negative_dt_returns_input() {
let g = Gravity::default();
assert!((g.decay(0.7, -10.0) - 0.7).abs() < 1e-6);
}
#[test]
fn reinforce_adds_boost() {
let g = Gravity::new(Params { boost: 0.25, ..Params::default() });
assert!((g.reinforce(0.5) - 0.75).abs() < 1e-6);
}
#[test]
fn is_visible_uses_horizon() {
let g = Gravity::new(Params { horizon: 0.1, ..Params::default() });
assert!(g.is_visible(0.10));
assert!(g.is_visible(0.99));
assert!(!g.is_visible(0.05));
}
#[test]
fn long_decay_keeps_mass_non_negative() {
let g = Gravity::default();
assert!(g.decay(1.0, 1_000_000_000.0) >= 0.0);
}
}
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "khipu-share"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "khipu-share — sobres de notas portables, firmados Ed25519 y direccionados por contenido (BLAKE3) sobre agora. La gravedad temporal es local: se comparte el contenido, no la atención."
[dependencies]
khipu-core = { path = "../khipu-core" }
agora-core = { workspace = true }
agora-keystore = { workspace = true }
rand = { workspace = true }
blake3 = { workspace = true }
postcard = { workspace = true }
serde = { workspace = true }
serde-big-array = "0.5"
thiserror = { workspace = true }
@@ -0,0 +1,180 @@
//! Descubrimiento de pares khipu en la LAN por baliza UDP.
//!
//! Un cuaderno que publica emite cada pocos segundos una [`Beacon`] —su
//! clave pública, el puerto TCP donde sirve y un nombre— por broadcast de
//! la LAN y por loopback. Quien quiere recibir escucha una ventana corta,
//! junta los pares vistos y le jala el cuaderno al primero (con
//! [`crate::net::fetch`]), sin tener que saber la IP de antemano.
//!
//! `std::net` puro, best-effort: redes que bloquean broadcast no
//! descubren nada, y ahí sigue valiendo apuntar a un par por dirección
//! explícita. El descubrimiento no afloja la seguridad: la baliza sólo
//! dice "dónde", el sobre que llega por TCP se verifica igual con
//! [`crate::open`].
use std::net::{Ipv4Addr, SocketAddr, UdpSocket};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
/// Puerto UDP estándar donde se emiten y escuchan las balizas.
pub const PUERTO_BALIZA: u16 = 7701;
/// Prefijo que marca un datagrama como baliza khipu — descarta tráfico
/// UDP ajeno antes de intentar parsearlo.
const MAGIA: [u8; 4] = *b"KHPU";
/// Lo que un cuaderno anuncia de sí mismo.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Beacon {
/// Clave pública Ed25519 del cuaderno (identidad de quien publica).
pub author: [u8; 32],
/// Puerto TCP donde ese cuaderno sirve el sobre (para `fetch`).
pub port: u16,
/// Nombre legible para mostrar en una lista de pares.
pub name: String,
}
impl Beacon {
/// Serializa la baliza al cable: `MAGIA` seguido del postcard.
pub fn encode(&self) -> Vec<u8> {
let mut out = MAGIA.to_vec();
if let Ok(body) = postcard::to_allocvec(self) {
out.extend_from_slice(&body);
}
out
}
/// Parsea un datagrama. `None` si no lleva la magia o no decodifica —
/// así un paquete UDP cualquiera no nos hace ruido.
pub fn decode(bytes: &[u8]) -> Option<Beacon> {
let body = bytes.strip_prefix(&MAGIA)?;
postcard::from_bytes(body).ok()
}
}
/// Un par visto en la red: dónde jalarle el cuaderno y qué anunció.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PeerVisto {
/// Dirección TCP de `fetch`: la IP de origen del datagrama + el
/// `port` de la baliza. El que anuncia no necesita conocer su propia
/// IP — la deduce quien recibe.
pub fetch_addr: SocketAddr,
pub beacon: Beacon,
}
/// Emite la baliza una vez: a broadcast de la LAN y a loopback (para que
/// dos cuadernos en la misma máquina se vean). Best-effort — los errores
/// de envío de cada destino se ignoran; sólo falla si no se pudo ni armar
/// el socket emisor.
pub fn anunciar(beacon: &Beacon) -> std::io::Result<()> {
let sock = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?;
sock.set_broadcast(true)?;
let bytes = beacon.encode();
let _ = sock.send_to(&bytes, (Ipv4Addr::BROADCAST, PUERTO_BALIZA));
let _ = sock.send_to(&bytes, (Ipv4Addr::LOCALHOST, PUERTO_BALIZA));
Ok(())
}
/// Escucha balizas en un socket ya bindeado durante `ventana`, devolviendo
/// los pares vistos (deduplicados por dirección de fetch). Bloqueante.
/// Separar el socket del bind permite testear sobre un puerto efímero.
pub fn escuchar_en(sock: &UdpSocket, ventana: Duration) -> Vec<PeerVisto> {
let _ = sock.set_read_timeout(Some(Duration::from_millis(150)));
let fin = Instant::now() + ventana;
let mut vistos: Vec<PeerVisto> = Vec::new();
let mut buf = [0u8; 2048];
while Instant::now() < fin {
match sock.recv_from(&mut buf) {
Ok((n, origen)) => {
if let Some(beacon) = Beacon::decode(&buf[..n]) {
let fetch_addr = SocketAddr::new(origen.ip(), beacon.port);
if !vistos.iter().any(|p| p.fetch_addr == fetch_addr) {
vistos.push(PeerVisto { fetch_addr, beacon });
}
}
}
Err(e)
if matches!(
e.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
) =>
{
continue
}
Err(_) => break,
}
}
vistos
}
/// Bindea el socket de escucha en el puerto estándar de balizas.
pub fn bind_balizas() -> std::io::Result<UdpSocket> {
UdpSocket::bind((Ipv4Addr::UNSPECIFIED, PUERTO_BALIZA))
}
/// Conveniencia: bindea el puerto estándar y escucha `ventana`.
pub fn descubrir(ventana: Duration) -> std::io::Result<Vec<PeerVisto>> {
Ok(escuchar_en(&bind_balizas()?, ventana))
}
#[cfg(test)]
mod tests {
use super::*;
fn beacon() -> Beacon {
Beacon {
author: [9u8; 32],
port: 7700,
name: "khipu de prueba".into(),
}
}
#[test]
fn encode_decode_roundtrips() {
let b = beacon();
assert_eq!(Beacon::decode(&b.encode()), Some(b));
}
#[test]
fn decode_rejects_non_beacon_traffic() {
assert_eq!(Beacon::decode(b"hola mundo"), None);
assert_eq!(Beacon::decode(b"KHP"), None); // magia truncada
assert_eq!(Beacon::decode(&[]), None);
}
#[test]
fn escuchar_recibe_una_baliza_por_loopback() {
// Escucha en un puerto efímero (no el estándar, para no chocar con
// otros tests ni con un khipu corriendo).
let listener = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let destino = listener.local_addr().unwrap();
let b = beacon();
let emisor = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).unwrap();
emisor.send_to(&b.encode(), destino).unwrap();
let vistos = escuchar_en(&listener, Duration::from_millis(500));
assert_eq!(vistos.len(), 1);
// La dirección de fetch combina la IP de origen con el port anunciado.
assert_eq!(vistos[0].fetch_addr.ip(), Ipv4Addr::LOCALHOST);
assert_eq!(vistos[0].fetch_addr.port(), b.port);
assert_eq!(vistos[0].beacon, b);
}
#[test]
fn escuchar_deduplica_balizas_repetidas() {
let listener = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let destino = listener.local_addr().unwrap();
let b = beacon();
let emisor = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).unwrap();
// Tres balizas idénticas: un solo par visto.
for _ in 0..3 {
emisor.send_to(&b.encode(), destino).unwrap();
}
let vistos = escuchar_en(&listener, Duration::from_millis(400));
assert_eq!(vistos.len(), 1);
}
}
@@ -0,0 +1,125 @@
//! Identidad del cuaderno, guardada cifrada con [`agora_keystore`].
//!
//! La clave privada (la semilla Ed25519 de 32 bytes) nunca queda en claro
//! en disco: vive cifrada bajo una passphrase (Argon2id + ChaCha20-Poly1305).
//! [`unlock`] la descifra a pedido. Quien tenga acceso de lectura al disco
//! no obtiene la identidad sin la passphrase — el endurecimiento frente al
//! `identidad.seed` en claro de las primeras versiones.
//!
//! Migración: si encuentra una semilla legacy en claro, la cifra dentro
//! del keystore y borra el archivo en claro, conservando la identidad.
use std::path::Path;
use agora_core::Keypair;
use agora_keystore::Keystore;
use rand::RngCore;
/// Falla al desbloquear o crear la identidad.
#[derive(Debug, thiserror::Error)]
pub enum IdentityError {
#[error("keystore: {0}")]
Keystore(String),
#[error("semilla legacy inválida (no son 32 bytes)")]
SemillaLegacy,
}
/// Desbloquea —o crea— la identidad del cuaderno guardada cifrada en
/// `keys_dir`, descifrándola con `passphrase`. Orden de resolución:
///
/// 1. Si el keystore ya tiene una identidad → la descifra. Passphrase
/// incorrecta ⇒ [`IdentityError::Keystore`] (no se distingue de otros
/// fallos del keystore a propósito: no filtramos si existe o no).
/// 2. Si hay una semilla legacy en claro en `legacy_seed` → la **migra**:
/// la cifra en el keystore con `passphrase`, borra el archivo en claro
/// y devuelve esa identidad.
/// 3. Si no hay nada → genera una identidad nueva (semilla de `OsRng`),
/// la guarda cifrada y la devuelve.
pub fn unlock(
keys_dir: &Path,
legacy_seed: Option<&Path>,
passphrase: &str,
) -> Result<Keypair, IdentityError> {
let ks = Keystore::open(keys_dir).map_err(|e| IdentityError::Keystore(e.to_string()))?;
let ids = ks.list().map_err(|e| IdentityError::Keystore(e.to_string()))?;
// 1. Identidad ya guardada: descifrar.
if let Some(id) = ids.first().copied() {
let seed = ks
.load(id, passphrase)
.map_err(|e| IdentityError::Keystore(e.to_string()))?;
return Ok(Keypair::from_seed(seed));
}
// 2. Migrar una semilla legacy en claro, si la hay.
if let Some(path) = legacy_seed {
if let Ok(bytes) = std::fs::read(path) {
let seed =
<[u8; 32]>::try_from(bytes.as_slice()).map_err(|_| IdentityError::SemillaLegacy)?;
let kp = Keypair::from_seed(seed);
ks.save(kp.identity_id(), &seed, passphrase)
.map_err(|e| IdentityError::Keystore(e.to_string()))?;
let _ = std::fs::remove_file(path); // ya está cifrada; fuera el claro
return Ok(kp);
}
}
// 3. Identidad nueva.
let mut seed = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut seed);
let kp = Keypair::from_seed(seed);
ks.save(kp.identity_id(), &seed, passphrase)
.map_err(|e| IdentityError::Keystore(e.to_string()))?;
Ok(kp)
}
#[cfg(test)]
mod tests {
use super::*;
/// Directorio temporal único por nombre de test (evita tempfile como
/// dependencia y las colisiones entre tests del mismo proceso).
fn temp_dir(etiqueta: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("khipu-id-{}-{}", std::process::id(), etiqueta));
let _ = std::fs::remove_dir_all(&d);
d
}
#[test]
fn crear_y_desbloquear_da_la_misma_identidad() {
let dir = temp_dir("crear");
let a = unlock(&dir, None, "secreta").unwrap();
let b = unlock(&dir, None, "secreta").unwrap();
assert_eq!(a.public_key(), b.public_key());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn passphrase_incorrecta_falla() {
let dir = temp_dir("malpass");
let _ = unlock(&dir, None, "correcta").unwrap();
assert!(unlock(&dir, None, "incorrecta").is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn migra_semilla_legacy_en_claro() {
let base = temp_dir("migra");
std::fs::create_dir_all(&base).unwrap();
let legacy = base.join("identidad.seed");
let semilla = [7u8; 32];
std::fs::write(&legacy, semilla).unwrap();
let keys = base.join("keys");
let kp = unlock(&keys, Some(&legacy), "secreta").unwrap();
// Conserva la identidad de la semilla legacy.
assert_eq!(kp.public_key(), Keypair::from_seed(semilla).public_key());
// El archivo en claro fue borrado.
assert!(!legacy.exists());
// Y ahora se descifra del keystore, sin legacy.
let kp2 = unlock(&keys, None, "secreta").unwrap();
assert_eq!(kp2.public_key(), kp.public_key());
let _ = std::fs::remove_dir_all(&base);
}
}
+349
View File
@@ -0,0 +1,349 @@
//! `khipu-share` — compartir notas por la red soberana de gioser 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");
//! ```
#![forbid(unsafe_code)]
pub mod discovery;
pub mod identity;
pub mod net;
use std::collections::HashSet;
use agora_core::{verify_signature, Keypair};
use khipu_core::{Note, NoteId, NoteStore};
use serde::{Deserialize, Serialize};
use serde_big_array::BigArray;
/// El contenido transferible de una nota — sin id, masa ni timestamps.
/// La gravedad temporal se queda en el cuaderno de origen.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SharedNote {
pub title: String,
pub body: String,
pub tags: Vec<String>,
}
impl SharedNote {
/// Proyecta una [`Note`] local a su contenido compartible, descartando
/// id, masa y marcas de tiempo.
pub fn from_note(n: &Note) -> Self {
Self {
title: n.title.clone(),
body: n.body.clone(),
tags: n.tags.clone(),
}
}
}
/// El cuerpo firmable: el autor, cuándo se selló, y las notas. Es lo que
/// se serializa y se hashea — el orden de las notas y las etiquetas es
/// significativo para que el hash sea estable.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Bundle {
/// Clave pública Ed25519 del autor (32 bytes).
pub author: [u8; 32],
/// Segundo Unix en que se selló el sobre.
pub created_at: u64,
pub notes: Vec<SharedNote>,
}
/// Un [`Bundle`] con la firma del autor sobre su hash de contenido.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SignedBundle {
pub bundle: Bundle,
/// Firma Ed25519 sobre [`Bundle::content_hash`].
#[serde(with = "BigArray")]
pub signature: [u8; 64],
}
impl Bundle {
/// La dirección de contenido del sobre: `BLAKE3(postcard(self))`. Es
/// también el mensaje que el autor firma.
pub fn content_hash(&self) -> Result<[u8; 32], ShareError> {
let bytes = postcard::to_allocvec(self).map_err(|_| ShareError::Serializacion)?;
Ok(*blake3::hash(&bytes).as_bytes())
}
}
impl SignedBundle {
/// Serializa el sobre a bytes (postcard) para escribirlo a disco o
/// mandarlo por cualquier canal.
pub fn to_bytes(&self) -> Result<Vec<u8>, ShareError> {
postcard::to_allocvec(self).map_err(|_| ShareError::Serializacion)
}
/// Reconstruye un sobre desde bytes. No verifica la firma — para eso
/// está [`open`].
pub fn from_bytes(bytes: &[u8]) -> Result<Self, ShareError> {
postcard::from_bytes(bytes).map_err(|_| ShareError::Serializacion)
}
/// La dirección de contenido del sobre (delegada a [`Bundle::content_hash`]).
pub fn content_address(&self) -> Result<[u8; 32], ShareError> {
self.bundle.content_hash()
}
}
/// Sella las notas en un sobre firmado por `kp`.
pub fn seal(
kp: &Keypair,
notes: Vec<SharedNote>,
created_at: u64,
) -> Result<SignedBundle, ShareError> {
let bundle = Bundle {
author: kp.public_key(),
created_at,
notes,
};
let hash = bundle.content_hash()?;
let signature = kp.sign(&hash);
Ok(SignedBundle { bundle, signature })
}
/// Abre un sobre: recomputa su hash de contenido y verifica que la firma
/// corresponda a la clave del autor declarado. Devuelve el [`Bundle`]
/// verificado, o un error si el contenido fue alterado o la firma no
/// corresponde. Verificación offline — sin autoridad central.
pub fn open(signed: &SignedBundle) -> Result<&Bundle, ShareError> {
let hash = signed.bundle.content_hash()?;
verify_signature(&signed.bundle.author, &hash, &signed.signature)
.map_err(|_| ShareError::FirmaInvalida)?;
Ok(&signed.bundle)
}
/// Resultado de ingerir un sobre en un cuaderno.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ImportOutcome {
/// Ids de las notas efectivamente creadas.
pub created: Vec<NoteId>,
/// Notas omitidas por título ya presente (la importación repetida es
/// inofensiva — no duplica).
pub skipped: usize,
}
/// Inserta las notas de un `bundle` en `store` como notas nuevas: id e
/// historia local frescos, `mass = 1.0`, `last_access = now`. La gravedad
/// arranca en el cuaderno receptor.
///
/// Las notas cuyo título (sin distinguir mayúsculas) ya existe en el
/// cuaderno se omiten, de modo que importar dos veces el mismo sobre no
/// duplica. Los wiki-links `[[Título]]` sobreviven solos: khipu resuelve
/// enlaces por título, así que si las notas enlazadas también se importan,
/// el grafo se rearma sin remapear ids.
///
/// Cada nota importada recibe una etiqueta de procedencia [`tag_de`]
/// (`de:<hex8 del autor>`) — así queda asentado de quién vino sin tocar
/// el modelo `Note` ni la persistencia. Es una etiqueta normal: visible,
/// buscable y removible.
pub fn import_into(store: &mut NoteStore, bundle: &Bundle, now: u64) -> ImportOutcome {
let mut existing: HashSet<String> =
store.iter().map(|n| n.title.to_lowercase()).collect();
let marca = tag_de(&bundle.author);
let mut out = ImportOutcome::default();
for sn in &bundle.notes {
let key = sn.title.to_lowercase();
// Sólo deduplicamos por títulos no vacíos: dos "(sin título)" no
// son la misma nota.
if !sn.title.is_empty() && existing.contains(&key) {
out.skipped += 1;
continue;
}
let mut tags = sn.tags.clone();
if !tags.iter().any(|t| t == &marca) {
tags.push(marca.clone());
}
let id = store.create(sn.title.clone(), sn.body.clone(), tags, now);
existing.insert(key);
out.created.push(id);
}
out
}
/// Prefijo hex (4 bytes / 8 hex) de una clave o hash — identifica un
/// autor de forma legible sin volcar los 32 bytes.
pub fn hex8(bytes: &[u8; 32]) -> String {
bytes[..4].iter().map(|b| format!("{b:02x}")).collect()
}
/// La etiqueta de procedencia para un autor: `de:<hex8>`.
pub fn tag_de(author: &[u8; 32]) -> String {
format!("de:{}", hex8(author))
}
/// Falla al sellar, abrir o transportar un sobre.
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ShareError {
#[error("no se pudo (de)serializar el sobre")]
Serializacion,
#[error("la firma no corresponde al contenido y la clave del autor")]
FirmaInvalida,
}
#[cfg(test)]
mod tests {
use super::*;
fn nota(title: &str, body: &str, tags: &[&str]) -> SharedNote {
SharedNote {
title: title.into(),
body: body.into(),
tags: tags.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn seal_then_open_roundtrips() {
let kp = Keypair::from_seed([1u8; 32]);
let notes = vec![nota("A", "cuerpo a", &["x"]), nota("B", "[[A]]", &[])];
let sobre = seal(&kp, notes.clone(), 42).unwrap();
let bundle = open(&sobre).unwrap();
assert_eq!(bundle.author, kp.public_key());
assert_eq!(bundle.created_at, 42);
assert_eq!(bundle.notes, notes);
}
#[test]
fn content_hash_is_stable_and_content_addressed() {
let kp = Keypair::from_seed([2u8; 32]);
let notes = vec![nota("A", "a", &["t"])];
let a = seal(&kp, notes.clone(), 100).unwrap();
let b = seal(&kp, notes, 100).unwrap();
// Mismas notas + mismo autor + mismo instante → mismo hash y firma.
assert_eq!(a.content_address().unwrap(), b.content_address().unwrap());
assert_eq!(a.signature, b.signature);
}
#[test]
fn tampering_with_content_is_rejected() {
let kp = Keypair::from_seed([3u8; 32]);
let mut sobre = seal(&kp, vec![nota("real", "intacto", &[])], 1).unwrap();
// Alteramos el cuerpo sin rehacer la firma.
sobre.bundle.notes[0].body = "manipulado".into();
assert_eq!(open(&sobre), Err(ShareError::FirmaInvalida));
}
#[test]
fn forged_author_is_rejected() {
let autor = Keypair::from_seed([4u8; 32]);
let impostor = Keypair::from_seed([5u8; 32]);
let mut sobre = seal(&autor, vec![nota("n", "c", &[])], 1).unwrap();
// El impostor pone su clave pero no puede producir la firma válida.
sobre.bundle.author = impostor.public_key();
assert_eq!(open(&sobre), Err(ShareError::FirmaInvalida));
}
#[test]
fn bytes_roundtrip_preserves_verification() {
let kp = Keypair::from_seed([6u8; 32]);
let sobre = seal(&kp, vec![nota("n", "c", &["a", "b"])], 9).unwrap();
let bytes = sobre.to_bytes().unwrap();
let recuperado = SignedBundle::from_bytes(&bytes).unwrap();
assert_eq!(recuperado, sobre);
assert!(open(&recuperado).is_ok());
}
#[test]
fn import_creates_fresh_notes_with_local_gravity() {
let kp = Keypair::from_seed([7u8; 32]);
let sobre = seal(&kp, vec![nota("Receta", "sopa", &["cocina"])], 1).unwrap();
let bundle = open(&sobre).unwrap();
let mut store = NoteStore::new();
let out = import_into(&mut store, bundle, 5_000);
assert_eq!(out.created.len(), 1);
assert_eq!(out.skipped, 0);
let n = store.get(out.created[0]).unwrap();
assert_eq!(n.title, "Receta");
// Conserva sus etiquetas y suma la de procedencia.
assert!(n.tags.contains(&"cocina".to_string()));
assert!(n.tags.iter().any(|t| t.starts_with("de:")));
// Gravedad fresca: masa plena y acceso = ahora del receptor.
assert_eq!(n.mass, 1.0);
assert_eq!(n.last_access, 5_000);
assert_eq!(n.created_at, 5_000);
}
#[test]
fn import_marks_author_provenance() {
let kp = Keypair::from_seed([13u8; 32]);
let sobre = seal(&kp, vec![nota("N", "c", &[])], 1).unwrap();
let bundle = open(&sobre).unwrap();
let mut store = NoteStore::new();
let out = import_into(&mut store, bundle, 1);
let n = store.get(out.created[0]).unwrap();
assert!(n.tags.contains(&tag_de(&kp.public_key())));
}
#[test]
fn reimport_skips_existing_titles() {
let kp = Keypair::from_seed([8u8; 32]);
let sobre = seal(
&kp,
vec![nota("A", "a", &[]), nota("B", "b", &[])],
1,
)
.unwrap();
let bundle = open(&sobre).unwrap();
let mut store = NoteStore::new();
let first = import_into(&mut store, bundle, 10);
assert_eq!(first.created.len(), 2);
// Segunda importación del mismo sobre: nada nuevo.
let second = import_into(&mut store, bundle, 20);
assert_eq!(second.created.len(), 0);
assert_eq!(second.skipped, 2);
assert_eq!(store.len(), 2);
}
#[test]
fn wiki_links_survive_import_by_title() {
let kp = Keypair::from_seed([9u8; 32]);
let sobre = seal(
&kp,
vec![
nota("Índice", "ver [[Receta]]", &[]),
nota("Receta", "sopa", &[]),
],
1,
)
.unwrap();
let bundle = open(&sobre).unwrap();
let mut store = NoteStore::new();
let out = import_into(&mut store, bundle, 1);
let indice = out.created[0];
// El enlace [[Receta]] resuelve a la otra nota importada.
assert_eq!(store.forward_links(indice).len(), 1);
}
}
+155
View File
@@ -0,0 +1,155 @@
//! Transporte LAN mínimo para sobres khipu sobre TCP.
//!
//! El sobre ya es firmado y direccionado por contenido, así que el
//! transporte no necesita ser confiable: quien recibe verifica con
//! [`crate::open`] antes de creer nada. Esto es `std::net` puro — sin
//! libp2p ni async — pensado para "jalar el cuaderno de un par en la LAN".
//!
//! Marco de cable: un `u32` big-endian con el largo del sobre, seguido
//! del sobre serializado (postcard). Un sobre por conexión.
use std::io::{self, Read, Write};
use std::net::{TcpListener, TcpStream, ToSocketAddrs};
use crate::{ShareError, SignedBundle};
/// Tope defensivo para un sobre entrante (64 MiB): evita que un par
/// hostil pida un alloc gigante declarando un largo inflado.
const MAX_SOBRE: u32 = 64 * 1024 * 1024;
/// Falla del transporte de sobres.
#[derive(Debug, thiserror::Error)]
pub enum NetError {
#[error("io de red: {0}")]
Io(String),
#[error("marco inválido: {0}")]
Protocolo(String),
#[error(transparent)]
Sobre(#[from] ShareError),
}
impl From<io::Error> for NetError {
fn from(e: io::Error) -> Self {
NetError::Io(e.to_string())
}
}
fn write_frame(stream: &mut TcpStream, payload: &[u8]) -> io::Result<()> {
if payload.len() as u64 > MAX_SOBRE as u64 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"sobre demasiado grande para el marco",
));
}
stream.write_all(&(payload.len() as u32).to_be_bytes())?;
stream.write_all(payload)?;
stream.flush()
}
fn read_frame(stream: &mut TcpStream) -> Result<Vec<u8>, NetError> {
let mut len_buf = [0u8; 4];
stream.read_exact(&mut len_buf)?;
let len = u32::from_be_bytes(len_buf);
if len > MAX_SOBRE {
return Err(NetError::Protocolo(format!(
"largo declarado {len} excede el tope"
)));
}
let mut buf = vec![0u8; len as usize];
stream.read_exact(&mut buf)?;
Ok(buf)
}
/// Conecta a `addr`, lee un sobre y lo deserializa. **No lo verifica** —
/// el caller debe pasar el resultado por [`crate::open`] antes de confiar.
pub fn fetch(addr: impl ToSocketAddrs) -> Result<SignedBundle, NetError> {
let mut stream = TcpStream::connect(addr)?;
let bytes = read_frame(&mut stream)?;
Ok(SignedBundle::from_bytes(&bytes)?)
}
/// Atiende una sola conexión: manda `payload` y vuelve. Útil para tests
/// y para un "compartir una vez".
pub fn serve_once(listener: &TcpListener, payload: &[u8]) -> io::Result<()> {
let (mut stream, _) = listener.accept()?;
write_frame(&mut stream, payload)
}
/// Atiende conexiones para siempre. Por cada una llama a `supply` para
/// obtener los bytes a mandar — típicamente leer `compartido.khipu` del
/// disco, así sirve siempre la versión vigente. Una conexión cuyo
/// `supply` falla (todavía no hay sobre, p. ej.) se salta sin tumbar el
/// servidor. Bloqueante: pensado para correr en su propio hilo.
pub fn serve_loop<F>(listener: TcpListener, supply: F)
where
F: Fn() -> io::Result<Vec<u8>>,
{
for conn in listener.incoming() {
let Ok(mut stream) = conn else { continue };
if let Ok(bytes) = supply() {
let _ = write_frame(&mut stream, &bytes);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{open, seal, SharedNote};
use agora_core::Keypair;
fn nota(title: &str, body: &str) -> SharedNote {
SharedNote {
title: title.into(),
body: body.into(),
tags: Vec::new(),
}
}
#[test]
fn fetch_recovers_a_served_bundle_and_verifies() {
let kp = Keypair::from_seed([11u8; 32]);
let sobre = seal(&kp, vec![nota("Red", "hola por TCP")], 1).unwrap();
let bytes = sobre.to_bytes().unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let server = std::thread::spawn(move || serve_once(&listener, &bytes).unwrap());
let recibido = fetch(addr).unwrap();
server.join().unwrap();
assert_eq!(recibido, sobre);
// El sobre que llegó por la red verifica firma + hash.
let bundle = open(&recibido).unwrap();
assert_eq!(bundle.notes[0].title, "Red");
}
#[test]
fn serve_loop_serves_the_current_supply_each_time() {
let kp = Keypair::from_seed([12u8; 32]);
let sobre = seal(&kp, vec![nota("A", "a")], 1).unwrap();
let bytes = sobre.to_bytes().unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
// El servidor corre en su hilo; lo dejamos colgado al terminar el
// test (los dos fetch ya probaron lo que importa).
std::thread::spawn(move || {
serve_loop(listener, move || Ok(bytes.clone()));
});
// Dos pares distintos jalan el mismo cuaderno.
let a = fetch(addr).unwrap();
let b = fetch(addr).unwrap();
assert_eq!(a, sobre);
assert_eq!(b, sobre);
}
#[test]
fn fetch_against_nothing_is_an_error_not_a_panic() {
// Puerto cerrado: connect falla, devolvemos NetError::Io.
let err = fetch("127.0.0.1:1").unwrap_err();
assert!(matches!(err, NetError::Io(_)));
}
}
@@ -0,0 +1,75 @@
//! Integración: el camino completo de compartir por LAN, todo en loopback.
//!
//! Sella un cuaderno → lo sirve por TCP → anuncia su baliza → otro par lo
//! descubre por UDP, le jala el sobre y lo verifica. Es la cadena que
//! ejercitan los botones «publicar» y «recibir» de khipu-app, sin GUI.
use std::net::{Ipv4Addr, TcpListener, UdpSocket};
use std::time::Duration;
use agora_core::Keypair;
use khipu_core::NoteStore;
use khipu_share::discovery::{escuchar_en, Beacon};
use khipu_share::{net, open, seal, SharedNote};
#[test]
fn descubrir_jalar_y_verificar_de_punta_a_punta() {
// --- Lado que publica ---
let autor = Keypair::from_seed([21u8; 32]);
let sobre = seal(
&autor,
vec![SharedNote {
title: "Compartida por LAN".into(),
body: "viajó por descubrimiento + TCP".into(),
tags: vec!["red".into()],
}],
100,
)
.unwrap();
let bytes = sobre.to_bytes().unwrap();
// Sirve el sobre por TCP en un puerto efímero.
let tcp = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let tcp_port = tcp.local_addr().unwrap().port();
std::thread::spawn(move || {
let _ = net::serve_once(&tcp, &bytes);
});
// Anuncia la baliza apuntando a ese puerto TCP.
let beacon = Beacon {
author: autor.public_key(),
port: tcp_port,
name: "khipu publicador".into(),
};
// --- Lado que recibe ---
// Escucha balizas en un puerto UDP efímero (en la app sería el
// estándar 7701); el publicador le manda la suya por loopback.
let listener = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let destino = listener.local_addr().unwrap();
let emisor = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).unwrap();
emisor.send_to(&beacon.encode(), destino).unwrap();
// Descubre al par y le jala el cuaderno.
let pares = escuchar_en(&listener, Duration::from_millis(500));
assert_eq!(pares.len(), 1, "debería haber descubierto un par");
let par = &pares[0];
assert_eq!(par.fetch_addr.port(), tcp_port);
assert_eq!(par.beacon.author, autor.public_key());
let recibido = net::fetch(par.fetch_addr).unwrap();
// Verifica firma + hash e ingiere como nota fresca.
let bundle = open(&recibido).unwrap();
let mut store = NoteStore::new();
let resultado = khipu_share::import_into(&mut store, bundle, 9_000);
assert_eq!(resultado.created.len(), 1);
let nota = store.get(resultado.created[0]).unwrap();
assert_eq!(nota.title, "Compartida por LAN");
assert!(nota.tags.contains(&"red".to_string()));
// La nota lleva la procedencia del autor que la selló.
assert!(nota.tags.contains(&khipu_share::tag_de(&autor.public_key())));
// Gravedad fresca en el receptor.
assert_eq!(nota.mass, 1.0);
assert_eq!(nota.last_access, 9_000);
}